TypeScript Patterns
Intro
TypeScript earns its keep in strict mode with no any.
Discriminated unions model state, zod validates external data at
the boundary, and Result types keep library code from throwing on
expected failures.
Overview
Project setup
Enable strict mode in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
strict turns on a family of checks including noImplicitAny,
strictNullChecks, and strictFunctionTypes.
noUncheckedIndexedAccess makes array and object index access
return T | undefined, which catches a whole class of bugs.
Commit your lockfile whether you use pnpm, npm, or yarn.
Type safety
- Avoid
any. Useunknownand narrow with type guards orzod. - Give public functions explicit return types so inference changes can't silently reshape the API.
- Model state with discriminated unions:
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string }
- Prefer
interfacefor object shapes (they can be extended and merged) andtypefor unions and intersections.
Common patterns
as constfor literal-type locking and exhaustive switches.satisfiesfor checking a value against a type without widening it — you keep the narrow literal types for inference downstream.Map<K, V>overRecord<string, V>when keys are dynamic and iteration matters.zod(or similar) for runtime validation of anything crossing a process boundary: API responses, user input, environment variables.
Error handling
- Define custom error classes extending
Errorand set thenameproperty so logs identify them. - In library code, prefer Result types over throwing for expected failure modes. Throw for programmer errors and unrecoverable state.
- Validate external inputs at the boundary — once a value is inside your type system, the types should be trustworthy.
Testing
Use vitest or jest for runtime tests. For type tests, use
expectTypeOf (vitest) or similar to assert that types behave
correctly — catching regressions in generic helpers before they
reach callers.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Using
anyinstead ofunknownfor untyped boundaries.anydisables type checking for the variable and everything it touches downstream.unknownforces you to narrow before use — it's the type-safe alternative for values whose shape is genuinely unknown. - Disabling strict mode "temporarily" in a new project. A TypeScript project without
strict: trueis a JavaScript project with extra syntax. Enabling strict mode later is painful because the whole codebase has accumulated implicit nulls and anys. Enable it on day one. - Type assertions (
x as Foo) as a refactoring shortcut.asbypasses the type checker. It is appropriate for narrow cases like DOM element access or JSON parsing, not as a way to silence type errors without fixing them. Fix the real type mismatch. - Skipping runtime validation at process boundaries. TypeScript's types exist only at compile time. A REST API response, environment variable, or user input that passes type-checking at compile time can still have the wrong shape at runtime. Validate external data with
zodor equivalent at every boundary. - Using
Record<string, V>when aMap<K, V>is semantically correct.Recordis for objects with a known finite set of keys. Dynamic keys, iteration in insertion order, presence checks, and non-string keys all call forMap. The wrong choice leads to surprising behavior withhasOwnPropertyandfor...in. Optionalfield (?:) when the property is actually required. Marking a property optional (email?: string) says "this property may be absent". If the property is always present but sometimes has no value, useemail: string | nullinstead — the distinction affects how callers narrow the type.- No
nevercheck inswitchexhaustiveness. Without an explicitconst _exhaustive: never = sin thedefaultbranch, adding a new variant to a discriminated union compiles without error, producing a silent runtime gap. Always add the exhaustiveness check.
Full reference
Discriminated unions with exhaustive checks
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: number; height: number }
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2
case "square": return s.side ** 2
case "rect": return s.width * s.height
default: {
const _exhaustive: never = s
return _exhaustive
}
}
}
The never assignment makes adding a new variant a compile error
at every consumer. This is the TypeScript equivalent of Java's
sealed types or Rust's exhaustive match.
Runtime validation with zod
import { z } from "zod"
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
createdAt: z.coerce.date(),
})
type User = z.infer<typeof UserSchema>
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/users/${id}`)
return UserSchema.parse(await res.json())
}
Validate at the boundary; trust the types everywhere inside.
Never pass raw any / unknown API payloads into business
logic.
satisfies vs type annotation
// Widens to Record<string, string> — you lose the literal keys
const routesWide: Record<string, string> = {
home: "/",
about: "/about",
}
// Keeps literal types for keys and values; still type-checked
const routes = {
home: "/",
about: "/about",
} satisfies Record<string, string>
type RouteName = keyof typeof routes // "home" | "about"
Anti-patterns
anyanywhere it isn't strictly required to interop with untyped code. Useunknownand narrow.Optionalfield types viaT | undefinedwhen the property is actually required — use the?form only when the property may be absent.- Type assertions (
x as Foo) used as a refactoring shortcut instead of fixing the real type mismatch. - Returning different shapes from the same function based on arguments — use overloads or discriminated unions.
- Catching
Errorand losing the type — re-throw or convert to a Result. - Using
Record<string, V>when you actually need aMap<K, V>(iteration order, non-string keys, presence checks). - Disabling strict mode "temporarily" in a new project.
Custom error class
export class NotFoundError extends Error {
constructor(public readonly resource: string, id: string) {
super(`${resource} ${id} not found`)
this.name = "NotFoundError"
}
}
Set name explicitly — the default is "Error", which makes
log filtering and instanceof checks across realms unreliable.