Write and review TypeScript with reliable domain types
Model valid states, validate external data at boundaries, and verify TypeScript changes without hiding errors behind casts.
Use this when
You are reading, editing, or reviewing .ts or .tsx files and need to check whether their types express the domain accurately. Use it for function signatures, state models, external input, and code that relies on casts or non-null assertions.
The result is a change or review that identifies where data becomes trusted, makes invalid combinations harder to construct, and checks the affected behavior in the real project.
Steps
1. Inspect the domain and the project's existing tools
Read the relevant types, their producers and consumers, repository instructions, TypeScript configuration, and documented build and test scripts. Find existing runtime schemas, generated protocol or database types, validation helpers, and structured logging facilities before introducing alternatives.
For the code under review, identify:
- The valid states and combinations of fields, including what happens when a collection is empty.
- Primitives that have different meanings despite sharing a representation, such as user IDs and order IDs.
- Where input enters from JSON, network messages, IPC, files, environment variables, command-line arguments, or database results.
- Places that use
any,as,!, or a throw for a supposedly impossible case. Trace the missing guarantee back to the input or model that should establish it.
Choose the simplest type for which each operation handles every permitted input. Strengthen a type where an operation otherwise needs an unchecked assumption; keep ordinary types where all their values already have a defined result.
2. Represent valid states directly
Replace boolean flags and optional fields that admit contradictory states with a discriminated union. Use a consistent literal discriminant, such as kind, and keep each variant's required data in that variant.
type LoadState<T> =
| { kind: "loading" }
| { kind: "ready"; value: T }
| { kind: "error"; message: string };
Choose representations that carry the guarantees consumers need:
- Use
[T, ...T[]]when a function requires at least one item. Check an incoming ordinary array once before treating it as non-empty. If absence is a valid result, returningT | undefinedis another complete signature. - Use
[T, T][]when values are consumed as pairs. - Represent a range as a start and duration when that fits the domain, deriving its end when needed. Validate the duration at creation; a plain
numberstill permits negative values. - Keep
T[]for operations such as summing with an initial value that also work on an empty array.
Give semantically different primitives distinct branded types when mixing them up would be a mistake. Use string & { readonly __brand: "UserId" }, with a different brand for each domain concept. Construct the brand only after its boundary parser has checked the value. Avoid branding values merely to describe them more precisely when no consumer needs the distinction.
3. Parse external input into named domain types
Treat external data as unknown until it has been validated. At the entry point, parse it into a named domain type and give internal functions that type. Keep Record<string, unknown> and equivalent unstructured representations within the parsing layer.
Use the repository's runtime schema library and existing schemas before writing a property-by-property guard. Infer the TypeScript type from the schema, for example with z.infer in a project that already uses Zod. If a domain type already exists, type the validator against that contract so the compiler can catch a schema that proves too little. Do not add a new schema dependency for a single guard.
Handle invalid input at the boundary. Use the schema library's non-throwing parse result when invalid input is an expected branch. For persisted JSON, version the stored shape and handle parse failures there. For wire formats that support it, use the parser's unknown-field handling to preserve forward compatibility.
After a successful parse, pass trusted domain values inward instead of repeatedly validating them in business logic. Keep framework and transport handling at the edge, with business decisions in functions that can be tested independently.
TypeScript annotations, brands, and satisfies do not perform runtime validation. An assertion cannot substitute for checking external values.
4. Narrow values and handle every variant
Prefer narrowing in this order: a discriminant switch or condition, the in operator, typeof or instanceof, then a user-defined guard. Use an as assertion only after validation has established the claimed fact and ordinary narrowing cannot express it, such as returning a validated branded primitive.
A guard must check everything its predicate claims, including required fields and their types. Name guards isX or hasX; checking that one property exists does not prove an entire object shape.
For a discriminated union switch, assign the remaining value to never in the default arm. This makes a newly added variant require a corresponding handler.
function describeLoad(state: LoadState<string>): string {
switch (state.kind) {
case "loading":
return "Loading";
case "ready":
return state.value;
case "error":
return state.message;
default: {
const _exhaustive: never = state;
return _exhaustive;
}
}
}
For a switch that returns no value, use void _exhaustive; after the never assignment instead of returning it. When removing a cast or non-null assertion, repair the missing discriminant, wide input type, boundary parser, or function signature that made it necessary.
5. Reuse authoritative types and clear interfaces
Derive shapes from generated protocol, API, GraphQL, or database types when those definitions already own the data. Use Pick, Omit, Parameters, ReturnType, Awaited, or typeof before declaring an interface that duplicates an existing shape.
For locally constructed values, prefer satisfies to an as assertion when checking compatibility while retaining useful inference. It checks assignability at compile time; it does not make an incompatible value valid.
Prefer an object argument when several positional arguments can be confused. Keep the positional form on hot paths where the object allocation cost matters, such as per-frame rendering, tokenizers, or parsers.
Use the project's structured logger for shipped diagnostics, with enough context to investigate a failure from an identifier. Replace ad hoc console.log calls in shipped code with that facility.
6. Verify the affected behavior and review the diff
Run the repository's documented TypeScript checks and relevant build or lint checks. Exercise the affected behavior with the framework's real test primitives; use its leak and disposable checks where available. Run local dependencies that can be exercised, and reserve mocks for systems that cannot run locally. For UI changes, verify the affected path in a running build.
Check the paths implicated by the model: valid and invalid boundary input, each affected union variant, and empty collections when callers can supply them. Confirm that validation failures are handled at entry points and that internal callers receive the promised domain types.
Read the final diff for duplicated schemas or interfaces, unchecked casts, non-null assertions, incomplete guards, missing union cases, redundant internal validation, and temporary diagnostics. If verification started a process or created scratch data, stop that process and remove that scratch data after preserving the results. Report checks performed and any environment or behavior that remains unverified.
Done when
- Valid domain states and required collection guarantees are expressed in types without unnecessary strengthening.
- External input is validated at its entry point and becomes a named domain type for internal callers.
- Assertions and guards have a proven basis, and affected unions are handled exhaustively.
- Authoritative schemas and types are reused, with clear arguments and structured diagnostics.
- Project checks and the affected real behavior were verified, or exact blockers and unverified paths are recorded.
- The final diff was reviewed and temporary verification state was cleaned up.