These standards describe how to design and write TypeScript code in this codebase. They are especially intended for agents: before adding patterns, libraries, adapters, or abstractions, read the existing code and prefer the local convention unless it conflicts with the safety/correctness principles below.
When rules pull in different directions, use this order:
- Preserve correctness, safety, and debuggability.
- Follow established project architecture and conventions.
- Improve the local design toward these standards.
- Avoid broad migrations unless explicitly requested.
- Document meaningful trade-offs with comments or ADRs.
New code paths, modules, adapters, and services should generally follow these standards, but do not force a whole-project migration for an unrelated change.
- Prefer errors as values over
throw/ rejected promises for expected failures. - Parse early. Do not merely validate and throw away the information learned.
- Make illegal states unrepresentable where practical.
- Prefer correct-by-construction APIs over convention-based invariants.
- Use branded/refined/domain types liberally for meaningful primitives.
- Prefer composition over inheritance.
- Prefer imperative shell / functional core.
- Design deep, cohesive modules with low caller burden.
- Test behavior through real seams; avoid module mocks and spy-driven tests.
- Keep code discoverable for humans and agents.
Before adding a new pattern or library, inspect the repo for existing choices around:
- error handling
- schema parsing
- dependency injection
- testing
- observability
- adapters/services
- module layout
Prefer consistency inside the codebase. If existing code uses exception-style errors, do not rewrite the whole system. New code may still use typed results internally, but it must integrate with existing framework handlers, logging, tracing, metrics, and error reporting.
At boundaries, translate between local typed errors and whatever the framework or existing code expects.
Expected failures include domain, parsing, authorization, integration, I/O, persistence, and workflow failures. They should appear in the return type.
Preferred order:
- Effect, when the codebase already uses Effect.
better-result, when available and appropriate.- A small local tagged union:
type Result<T, E extends Error> =
| { readonly _tag: "ok"; readonly value: T }
| { readonly _tag: "err"; readonly error: E };Prefer:
Promise<Result<User, UserLookupError>>not:
Promise<User> // rejects for ordinary lookup/storage failuresPromise rejection is equivalent to throwing. Treat it as acceptable only for unrecoverable defects or unclassified third-party behavior at a boundary.
Throwing is acceptable for panic-style failures:
- violated internal invariants
- impossible branches
- startup misconfiguration
- temporary
notYetImplementedpaths - catastrophic runtime conditions
Use shared helpers from prelude.ts where available:
export function casesHandled(unexpectedCase: never): never;
export function shouldNeverHappen(msg?: string): never;
export function notYetImplemented(msg?: string): never;Use casesHandled for exhaustive union handling. Avoid names like absurd or one-off assertNever helpers when the project already has these helpers.
Expected failures should use custom tagged errors, generally extending:
ErrorTaggedErrorfrombetter-resultSchema.TaggedErrorClassin Effect codebases
Custom errors should include:
- stable tag
- useful message
- structured contextual fields
- safe telemetry fields
- optional
cause: unknown
Example:
export class UserStoreUnavailable extends Error {
readonly _tag = "UserStoreUnavailable";
constructor(
readonly operation: "findActiveByEmail",
readonly provider: "postgres",
readonly cause: unknown,
) {
super(`User store unavailable during ${operation}`);
}
}Keep error unions precise at module boundaries:
Result<User, UserNotFound | UserStoreUnavailable>Avoid broad AppError-style types except near entrypoints, orchestration, logging, and rendering layers.
Prefer end-to-end structured tracing across requests, jobs, workflows, application modules, adapters, and external calls.
Tracing/logging should make failures diagnosable with safe fields:
- domain IDs
- operation names
- dependency/provider names
- state tags
- retry counts
- typed error tags
- safe summaries
Do not put secrets in errors, traces, logs, or snapshots.
Use a Redacted<T> wrapper for sensitive values such as tokens, API keys, passwords, raw credentials, and secrets. Prefer Effect's Redacted.Redacted in Effect codebases or a local Redacted<T> in prelude.ts.
Wrap sensitive values at the boundary and unwrap only where the raw value is needed, usually inside an adapter making an external call.
Boundary code should turn unknown or less-structured input into domain types as early as practical.
Prefer:
unknown -> HttpBodyDto -> CreateUserInput -> EmailAddress/UserId/etc.not:
unknown -> z.infer<typeof CreateUserSchema>passed throughout the app.
Use names that preserve meaning:
parseX(input): Result<X, ParseXError>for untrusted or less-structured inputmakeX(...)/createX(...)for smart constructors from already-typed piecesisX(value): booleanfor true predicatesassertX(...)rarely, mostly at tests/framework boundaries
Avoid validateX when the function returns a refined value. It parsed something.
Use schema libraries as boundary parsers, not as ad-hoc validators sprinkled through core logic.
Preference:
- use the repo's established schema library if one exists
- use Effect Schema in Effect codebases
- prefer Standard Schema compatibility for generic helpers
- otherwise prefer Zod 4
- use hand-written smart constructors/parsers for small domain types when clearer
Schema parsing should produce refined/domain types and typed custom errors where practical.
Use branded/refined types for meaningful primitives:
- IDs:
UserId,OrgId,WorkflowId - parsed strings:
EmailAddress,NonEmptyString,Url - constrained numbers:
PositiveInt,Cents,Percentage - units:
Milliseconds,Bytes,UsdCents
Construct branded values through parsers or smart constructors. Avoid passing raw strings/numbers where a domain type exists.
Avoid optional/null/undefined values in functions that require a value. Push optionality outward. Branch or parse before calling.
Avoid Partial<T> as an application/domain input unless partiality is the real domain concept. Prefer explicit input types for each operation.
When an entity has meaningful lifecycle states, model them with tagged unions or equivalent value classes.
Prefer:
type Invoice =
| { readonly _tag: "Draft"; readonly id: InvoiceId; readonly lines: NonEmptyArray<LineItem> }
| { readonly _tag: "Sent"; readonly id: InvoiceId; readonly sentAt: Instant }
| { readonly _tag: "Paid"; readonly id: InvoiceId; readonly paidAt: Instant };Avoid:
type Invoice = {
readonly isSent: boolean;
readonly isPaid: boolean;
readonly sentAt?: Date;
readonly paidAt?: Date;
};Avoid boolean parameters that control behavior:
createUser(input, true);Prefer named options or domain types:
createUser(input, { emailVerification: "skip" });Booleans are fine as clear predicate return values:
isExpired(token): boolean;
hasPermission(user, permission): boolean;A deep module hides substantial behavior/invariants behind a cohesive, low-burden interface. Low-burden does not necessarily mean few functions. A domain module may expose many cohesive combinators around one concept and still be deep.
Avoid shallow abstractions that merely forward calls, mirror tables, or expose implementation steps.
Use the deletion test:
- if deleting the module makes complexity disappear, it was probably pass-through waste
- if deleting it spreads complexity across callers, it was probably earning its keep
Prefer OCaml-style domain modules for core concepts. A domain module centers on one primary type or tightly related type family and exposes parsers, smart constructors, combinators, predicates, interpreters, arbitraries, and formatting helpers for that concept.
Example:
// email-address.ts
/** A parsed, normalized email address. */
export type EmailAddress = Brand<string, "EmailAddress">;
/** Parse an email address from untrusted input. */
export function parse(input: string): Result<EmailAddress, InvalidEmailAddress>;
/** Render an email address as a string. */
export function toString(email: EmailAddress): string;
/** Compare two email addresses for equality. */
export function equals(left: EmailAddress, right: EmailAddress): boolean;Domain modules may be plain functions, classes, or static-style classes when cohesive.
If using classes for domain values:
- construct through
parse/make/ smart constructors - make invalid instances unconstructable
- keep fields readonly/immutable from callers
- keep methods cohesive over that value
- do not hide dependencies or I/O inside domain value classes
- avoid inheritance for domain behavior
Application modules own real capabilities or operations:
PasswordResetBillingInvitationsSubscriptionLifecycle
They coordinate domain modules, persistence, external calls, authorization, workflows, and telemetry.
Prefer classes with constructor injection when the module has dependencies, stateful resources, configuration, or multiple cohesive operations.
Avoid dependency bags like deps objects passed into every function. In Effect codebases, use Effect services/tags/layers instead.
No arbitrary method limit. Split when methods are unrelated, change for different reasons, require unrelated dependencies, or create an accidental grab bag.
Avoid vague names like Manager, Processor, Helper, or generic UserService unless established by the framework/project.
Depend on the smallest meaningful shape a module actually uses. Let concrete adapters be wider.
Because TypeScript is structurally typed, this works well:
type UsersForPasswordReset = {
findActiveByEmail(email: EmailAddress): Promise<Result<ActiveUser, UserLookupError>>;
};
export class PasswordReset {
constructor(private readonly users: UsersForPasswordReset) {}
}A wider adapter can satisfy it:
export class PostgresUsers {
findActiveByEmail(...) { ... }
findById(...) { ... }
updateProfile(...) { ... }
}This avoids both mega-repositories and one-method adapter sprawl.
Before creating a new adapter or service, agents must audit existing adapters/services.
Prefer, in order:
- Reuse an existing adapter as-is through a narrow dependency type.
- Extend an existing adapter if the new method fits its existing cohesive capability and changes for the same reason.
- Create a new adapter only when reuse/extension would create bad coupling or an accidental interface.
When a meaningful new adapter/service is still created after the audit, create an ADR explaining:
- what existing adapters/services were checked
- why reuse did not fit
- why extension did not fit
- why the new adapter is a separate cohesive capability
Do not require an ADR for tiny local test adapters, obvious in-memory fakes, or trivial framework glue.
Avoid repository-per-table by default.
Repository-like adapters are acceptable when they represent a cohesive domain persistence capability. They should expose meaningful domain operations and return parsed domain types / typed errors, not raw rows and ORM errors.
Treat raw database rows and ORM models as infrastructure DTOs. Parse them before application/core logic. Keep SQL/ORM details inside infrastructure adapters or persistence modules.
Keep domain/application behavior reusable across REST, CLI, GraphQL, workers, and other entrypoints.
The functional core contains:
- domain logic
- parsers
- state transitions
- combinators
- decision functions
It avoids:
- I/O
- hidden dependencies
- ambient time/randomness
- thrown expected failures
- framework-specific concerns
The imperative shell:
- parses untrusted input
- sequences effects
- calls the core with refined values
- classifies external failures into typed errors
- handles I/O, persistence, HTTP, queues, telemetry, time, randomness
Entrypoint adapters should be thin protocol translation layers. They parse protocol-specific input, invoke shared modules, and render protocol-specific output. Do not duplicate business rules in controllers/resolvers/CLI handlers.
Authorization belongs in shared application/domain policy, not duplicated in controllers. Entrypoints may authenticate and parse users/sessions/credentials, but shared modules should receive a domain-specific parsed authorization input such as AdminUser, Session, Principal, DeployCredential, or CommandActor.
Use ordinary function calls or database transactions for simple single-boundary operations.
Use a saga/durable workflow when the process needs:
- retries
- compensation
- idempotency
- resumability
- timers
- human approval
- cross-service coordination
- multiple transaction boundaries
Do not hold database transactions open across network calls or long-running operations.
Any command, job, or workflow step that may be retried needs an explicit idempotency strategy:
- idempotency key
- natural unique constraint
- deduplication record
- state-machine transition guard
- transactional outbox/inbox
Retrying should not rely on “probably safe” side effects.
Prefer confidence-oriented tests:
- e2e for critical user flows
- integration tests through real seams
- focused/property tests for pure domain modules
- unit tests when they test meaningful behavior, not implementation details
Never use vi.mock or jest.mock for module mocking. Use real seams:
- constructor-injected interfaces/classes
- Effect services/layers
- local database substitutes such as SQLite
- in-memory adapters when behavior is simple
- fake external adapters when needed
Prefer tests that assert observable input/output behavior:
- returned value/error
- persisted state
- emitted event/message
- rendered response
- sent email record in a fake/local adapter
Avoid spy-driven tests like expect(sendEmail).toHaveBeenCalledWith(...) unless the interaction itself is the only observable behavior.
For persistence behavior, prefer SQLite/local DB-backed tests over hand-rolled in-memory fakes when SQL/schema/transaction behavior matters.
Use fast-check where properties are clearer than examples, especially for:
- parsers/smart constructors
- branded/refined types
- state machines
- serialization roundtrips
- normalization/idempotence
- lawful combinators
Use arbitraries for mock/test data generation. Prefer exporting arbitraries near the domain module they support:
src/billing/
invoice-number.ts
invoice-number.test.ts
invoice-number.arbitrary.tsTests should not bypass parsers, smart constructors, or invariants.
Use strict TypeScript settings where practical:
strict: truenoUncheckedIndexedAccess: trueexactOptionalPropertyTypes: truenoImplicitOverride: truenoFallthroughCasesInSwitch: true
Prefer immutable values:
type CreateUserInput = {
readonly email: EmailAddress;
readonly roles: ReadonlyArray<Role>;
};Mutation is acceptable inside localized imperative shell code, performance-sensitive internals, builders, or adapters when hidden behind a precise interface.
Avoid:
any- non-null assertions (
!) - casts with
as Type
as const is fine.
Rare exceptions are allowed for highly generic helpers, branding internals, interop boundaries, or combinators where TypeScript cannot express the invariant.
Any non-as const cast requires a Rust-like safety comment:
// SAFETY: TypeScript cannot express the brand. parseEmailAddress checked the normalized string before branding. Callers cannot construct EmailAddress except through this parser.
return normalized as EmailAddress;Rare any also requires a targeted oxlint ignore and justification:
// oxlint-disable-next-line no-explicit-any -- SAFETY: This helper preserves arbitrary function parameters; TypeScript cannot express this variadic constraint without any.
type Fn = (...args: any[]) => unknown;Do not use !. Branch, parse, or refine instead.
Prefer direct imports from the file that owns the abstraction. Avoid barrel files / index.ts re-export layers by default.
For domain modules, namespace imports often preserve the module shape:
import * as EmailAddress from "./email-address";
EmailAddress.parse(input);Use named imports for classes, prelude helpers, and focused shared helpers:
import { casesHandled } from "./prelude";
import { PasswordReset } from "./password-reset";Use import type / export type for type-only imports and exports.
Export only what callers should use. Keep internal helpers unexported unless intentionally shared. Do not export internals just for tests.
Avoid TypeScript namespace unless there is a compelling interop reason.
Avoid vague files:
utils.ts
helpers.ts
common.ts
misc.tsUse precise names:
email-address.ts
billing-period.ts
string-case.ts
array.ts
prelude.tsprelude.ts is allowed for tiny ubiquitous generic helpers/types such as:
casesHandledshouldNeverHappennotYetImplementedRedacted- common
Resulthelpers - broad type utilities
Do not put domain/application policy in prelude.ts.
No arbitrary file-size limits. Prefer cohesion and discoverability over small files for their own sake. Split when a file has multiple unrelated reasons to change or callers must understand unrelated concepts.
Comments should explain invariants, trade-offs, non-obvious domain rules, and safety justifications. Avoid comments that narrate obvious code.
Every exported function, class, method, constant, and usually exported type should have JSDoc.
Use standard JSDoc syntax:
/**
* Parse an email address from untrusted input.
*
* @param input - The untrusted string to parse.
* @returns A parsed email address, or `InvalidEmailAddress` when the input is invalid.
*/
export function parse(input: string): Result<EmailAddress, InvalidEmailAddress>;For generics:
/**
* Map the success value of a result.
*
* @template T - The original success type.
* @template U - The mapped success type.
* @template E - The error type.
* @param result - The result to map.
* @param fn - The function applied to the success value.
* @returns A result with the mapped success value, or the original error.
*/
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E>;Use @throws only for unrecoverable defects, framework-required behavior, or temporary notYetImplemented paths. Do not document expected typed errors as throws.
For complex exported object types, document fields when helpful:
/** Input required to create a user. */
export type CreateUserInput = {
/** The actor creating the user. */
readonly actor: AdminUser;
/** The parsed email address for the new user. */
readonly email: EmailAddress;
};Parse environment/config at startup or the earliest boundary into typed config with branded/redacted values where appropriate.
Do not read process.env throughout the app. Missing/invalid config is a startup failure with useful context.
Avoid top-level side effects except in true entrypoint/bootstrap files. Modules should not start servers, open connections, read env, register handlers, or perform I/O at import time.
Resource creation and cleanup should be explicit and owned by bootstrap/imperative shell code or Effect layers when using Effect.
Avoid mutable singletons/global state. Constants and pure lookup tables are fine. If a singleton is required by a framework/runtime, isolate it at the boundary.
Inject Clock / Random services into dependency-bearing modules. Pure domain functions may accept explicit now / random values.
Before coding:
- Read existing conventions for errors, schemas, tests, adapters, telemetry, and module layout.
- Look for existing domain modules/types before creating new ones.
- Look for existing adapters/services before creating a new one.
- Parse inputs at the edge and use domain types internally.
- Avoid raw DTOs, raw IDs, nullable bags, and
Partial<T>in core/application logic. - Prefer typed errors as values for new expected failures.
- Preserve existing observability/error mechanics.
- Test through public interfaces and real seams.
- Use
fast-checkarbitraries for generated test data when practical. - Add JSDoc for exported symbols.
- Add ADRs for meaningful new adapters/services created after an adapter reuse audit.
This draft intentionally stops before going deep on these areas. Cover them in a future grilling session:
-
Cloudflare development patterns
- Durable Objects
- Durable Workflows
- Workers/Hono request boundaries
- D1/R2/KV/Queues patterns
- local testing strategy
- where Cloudflare-specific code should live relative to domain/application modules
-
Effect development patterns
- services/tags/layers
Effecterror modelingSchema.TaggedErrorClassRedacted.Redacted- resource management/scopes
- testing Effect services
- when and how project code should structure Effect modules
-
More concrete examples
- bad/good parse-don't-validate examples
- custom error examples
- branded type examples
- service/application module examples
- adapter reuse audit examples
- testing examples with SQLite and
fast-check
-
Tooling details
- exact oxlint rules
- exact tsconfig baseline
- formatting/import rules
- test runner conventions
- JSDoc linting/enforcement