TypeScript patterns
Updated 2026-10-01
TypeScript patterns
Section titled “TypeScript patterns”Code examples for each rule in SKILL.md. The underlying principles are language-agnostic. See the type-system-discipline and boundary-discipline principle skills.
Branded types
Section titled “Branded types”Brand primitives so they can’t be mixed up. Validate once at the boundary. Downstream code trusts the type.
type AgentId = string & { readonly __brand: "AgentId" };
function parseAgentId(input: string): AgentId { if (!isUUID(input)) throw new Error(`Invalid agent id: ${input}`); return input as AgentId;}
function focusAgent(id: AgentId): void { /* input is trusted */}Match the readonly __brand: 'X' shape. Don’t invent a new convention.
Discriminated unions
Section titled “Discriminated unions”Model variants with a literal discriminant. Every variant shares the field name and each variant’s value is unique, so impossible combos can’t be represented.
// Don't. Boolean + optionals lets contradictory states exist.type DiffState = { loading: boolean; diff?: GitDiff; error?: string };
// Do. Only valid states exist.type DiffState = | { kind: "loading" } | { kind: "ready"; diff: GitDiff } | { kind: "error"; error: string };Pick one discriminant name (kind, type, tag) and stick to it.
Constructive modeling
Section titled “Constructive modeling”Build the type from parts that are all legal instead of restricting a loose type with runtime checks.
Non-empty, via a variadic tuple:
type NonEmpty<T> = [T, ...T[]];
// Don't: T[] plus a length check every caller must repeatfunction pickWinner(entries: string[]): string { if (entries.length === 0) throw new Error("no entries"); return entries[Math.floor(Math.random() * entries.length)];}
// Do: an empty value of the type can't existfunction pickWinner(entries: NonEmpty<string>): string { return entries[Math.floor(Math.random() * entries.length)];}Where a plain T[] arrives, narrow once with a guard. The fact then travels in the type:
const isNonEmpty = <T>(arr: T[]): arr is NonEmpty<T> => arr.length > 0;Even length, as pairs:
type Pairs<T> = [T, T][];A time range, as start plus duration:
// Don't: a comment holds the invarianttype TimeRange = { start: Date; end: Date }; // start <= end
// Do: a negative range can't be written; derive end when neededtype TimeRange = { start: Date; durationMs: number };Keep durationMs a plain number. Brand it (per Branded types) only if a raw number could be passed where a duration is expected, not by reflex. Pick the representation that makes the bad state unconstructable, then expose the reading you need on top (pairs.flat(), a rangeEnd() helper).
Simplest total type
Section titled “Simplest total type”Don’t strengthen everything. Keep T[] when every operation on it is total:
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0); // [] is 0, fineStrengthen when the loose type forces a lie at a use site. The tells are !, arr[0] as T, and a “should never happen” throw:
// Don't: partiality smuggled past the compilerfunction newestSession(sessions: Session[]): Session { return sessions.at(0)!;}
// Do: strengthen the input; the assertion disappearsfunction newestSession(sessions: NonEmpty<Session>): Session { return sessions[0];}Weakening the result to Session | undefined is the other total signature.
unknown over any
Section titled “unknown over any”External data is always unknown. Narrow before use.
// Don'tfunction handle(input: any) { return input.foo.bar;}
// Dofunction handle(input: unknown) { if (typeof input === "object" && input !== null && "foo" in input) { // narrowed; compiler verifies access }}External sources include RPC payloads, JSON.parse, postMessage, IPC, file contents, environment variables, database results.
Schemas before hand-rolled guards
Section titled “Schemas before hand-rolled guards”Before writing a property-by-property type guard for external data, look for the repository’s runtime schema library and existing schemas. Let one schema own validation and derive the TypeScript type from it. Do not maintain a schema, a duplicate interface, and a guard that can drift apart.
import { z } from "zod";
const UserSchema = z.object({ id: z.string().uuid(), role: z.enum(["admin", "member"]),});
type User = z.infer<typeof UserSchema>;
function parseUser(input: unknown): User { return UserSchema.parse(input);}Use safeParse when failure is an expected branch. Use the equivalent inference helper when the repository uses another schema library. Do not add a new schema dependency for one guard. This rule prefers the schema system the codebase already trusts.
No as casts
Section titled “No as casts”Every as is a potential runtime crash. Cast only after the type system has verified the claim.
// Don'tconst user = data as User;
// Do. Earn the cast at the boundary.function parseUser(data: unknown): User { if (typeof data !== "object" || data === null) { throw new Error("expected object"); } if (!("id" in data) || typeof (data as Record<string, unknown>).id !== "string") { throw new Error("expected id"); } // ... validate all fields return data as User; // OK, earned cast after full validation}When refactoring an as out of existing code, identify why TypeScript can’t infer:
- Missing discriminant: add one, switch to a discriminated union.
- Overly wide source type (e.g.
Record<string, unknown>): narrow it. - Untyped boundary: add a parse function or schema.
- Genuinely inexpressible: use a branded type or
satisfies.
Narrowing hierarchy
Section titled “Narrowing hierarchy”From best to last-resort:
- Discriminated union switch / if. Compiler narrows automatically.
inoperator."key" in objnarrows to variants containing that key.typeof/instanceof. For primitives and class instances.- User-defined type guard. When the above aren’t enough.
ascast. Only after validation.
function area(s: Shape): number { if ("radius" in s) return Math.PI * s.radius ** 2; // narrowed to circle return s.width * s.height; // narrowed to rect}Type guards
Section titled “Type guards”A guard must actually verify the claim. A lying guard is worse than as.
function isCircle(s: Shape): s is Shape & { kind: "circle" } { return s.kind === "circle";}Prefer discriminant narrowing when possible.
Exhaustiveness
Section titled “Exhaustiveness”In default arms, assign the discriminant to a never-typed local.
// Value-returning switchfunction area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "rect": return s.width * s.height; default: { const _exhaustive: never = s; return _exhaustive; } }}
// Void switchfunction handle(s: Shape): void { switch (s.kind) { case "circle": drawCircle(s); break; case "rect": drawRect(s); break; default: { const _exhaustive: never = s; void _exhaustive; } }}Return-style in value-returning switches, void-style in statement switches.
satisfies over as
Section titled “satisfies over as”satisfies validates without widening literal types.
// Don't. Widens, loses literal types.const config = { theme: "dark", cols: 3 } as Config;
// Do. Validates AND preserves literal types.const config = { theme: "dark", cols: 3 } satisfies Config;// config.theme is "dark" (literal), not stringBoundary validation
Section titled “Boundary validation”Validate once where data crosses in. Trust types inside. See the boundary-discipline principle skill.
- Wire formats (proto, JSON-RPC): parse with
ignoreUnknownFieldsso forward-compatible changes don’t break old clients. - Persisted JSON: versioned blob with a try/catch around the parse.
- Don’t re-validate deep in call chains.
Schema-derived types
Section titled “Schema-derived types”When a .proto, OpenAPI spec, GraphQL schema, or database migration already defines a shape, derive from the generated types instead of duplicating them.
// Don't. Duplicate shape, drifts when the schema changes.type CheckSummary = { totalCount: number; checks: { name: string; status: string }[];};function renderChecks(s: CheckSummary) { /* ... */}
// Do. Derive from the generated schema type.import type { ChecksMessage } from "<generated module>";function renderChecks(s: Pick<ChecksMessage, "totalCount" | "checks">) { /* ... */}Reach for Pick, Omit, Parameters, ReturnType, Awaited, typeof before writing a new interface.
Object args
Section titled “Object args”// Don't. Swap two args, still compiles.openFile(uri, { startLineNumber: 10, startColumn: 1, endLineNumber: 10, endColumn: 1,});
// Do. Order-independent, self-documenting.openFile({ uri, selection: { startLineNumber: 10, startColumn: 1, endLineNumber: 10, endColumn: 1, },});Skip on hot paths: per-frame render, tokenizers, parsers, anything in a tight loop where the allocation cost matters.
This is an unofficial Chinese learning site for pstack. It is not affiliated with poteto. Translations follow the pstack/ directory in the cursor/plugins repository. This site does not ship a Chinese plugin. github.com/cursor/plugins