TypeScript Type System
Primary reference: TypeScript docs. Section links below point at the exact page each concept comes from.
Quick Concept Index
| Problem / Topic | Concept |
|---|---|
| Safely handle API responses / JSON.parse | unknown vs any |
| Reuse an object without tying it to a class name | Structural typing |
| Explain why static types do not validate runtime data | Type erasure |
| Narrow a union based on runtime checks | Type narrowing & refinement |
| Route on message type with different shapes | Discriminated unions |
| Model entity lifecycle without invalid states | Make Illegal States Unrepresentable |
| Fail at compile time when a case is missed | Exhaustiveness / assertNever |
Create Partial, Readonly, or custom maps |
Mapped types |
Unwrap Promise<T>, filter union members |
Conditional types + infer |
| Why a callback with a wider parameter type is rejected (TS2345) | Variance (covariance / contravariance) |
| Carry narrowing across function boundaries | User-defined type guards |
Prevent mixing UserId and SessionToken |
Type branding (nominal types) |
| Pair a type and utility under one import | Companion object pattern |
| Preserve literal types in config objects | as const / type widening |
| Last-resort override of TypeScript's checks | Escape hatches (as T, !) |
Concepts
Structural typing — Compatibility is determined by shape, not by nominal inheritance. Prefer the narrowest useful shape; a name alone does not prevent mixing semantically different values. Use branding for domain IDs.
Avoid empty structures; they accept almost every value:
// Bad — no contract
interface Empty {}
type Anything = {};
See Type branding below for the full example.
Type erasure — Static T values do not survive into emitted JavaScript as runtime checks. Validate data that crosses a runtime boundary with Zod — see ../zod/SKILL.md.
unknown vs any — any disables checking; unknown forces narrowing before use. Default to unknown for external data (JSON.parse, API responses, user input). See The unknown type.
Type narrowing — TypeScript narrows union types through typeof, instanceof, in, equality checks, truthiness, and Array.isArray. Narrowing eliminates impossible branches. See Narrowing.
Discriminated unions — A shared literal tag field (kind, type, status) lets switch/if dispatch on shape. Essential for Redux actions, WebSocket messages, API variants. See Discriminated Unions.
Make Illegal States Unrepresentable — Model each state of an entity lifecycle as its own type in a discriminated union. Eliminates incoherent field combinations that optional fields allow.
Anti-pattern — single type with optional fields permits invalid states:
type Task = {
id: string;
title: string;
status: "PENDING" | "IN_PROGRESS" | "COMPLETED" | "FAILED" | "CANCELLED";
startedAt?: Date;
finishedAt?: Date;
error?: string;
cancelledBy?: string;
};
// Compiles fine: { status: "PENDING", finishedAt: new Date() } — incoherent
Fix — one type per state, shared base via intersection:
type AbstractTask = { id: string; title: string; createdAt: Date };
type PendingTask = AbstractTask & { status: "PENDING" };
type InProgressTask = AbstractTask & { status: "IN_PROGRESS"; startedAt: Date };
type CompletedTask = AbstractTask & {
status: "COMPLETED";
startedAt: Date;
finishedAt: Date;
};
type FailedTask = AbstractTask & {
status: "FAILED";
startedAt: Date;
finishedAt: Date;
error: string;
};
type CancelledTask = AbstractTask & {
status: "CANCELLED";
cancelledBy: string;
cancelledAt: Date;
};
type Task =
PendingTask | InProgressTask | CompletedTask | FailedTask | CancelledTask;
function handleTask(task: Task): void {
switch (task.status) {
case "COMPLETED":
console.log(
`Done in ${task.finishedAt.getTime() - task.startedAt.getTime()}ms`,
);
break;
case "FAILED":
console.error(task.error);
break;
case "CANCELLED":
console.log(`Cancelled by ${task.cancelledBy}`);
break;
}
}
Exhaustiveness checking — assertNever(value: never) produces a compile error when a new union member is added but not handled:
type Shape =
{ kind: "circle"; radius: number } | { kind: "square"; side: number };
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
return assertNever(shape); // compile error if a new Shape variant is added and unhandled
}
}
Mapped types — Transform every key of an existing type: { [K in keyof T]?: T[K] }. Built-ins: Partial, Required, Readonly, Pick, Record. See Mapped Types.
Conditional types — Type-level ternary: T extends U ? X : Y. With infer, extract type arguments at the type level. See Conditional Types.
User-defined type guards — Return value is T to carry narrowing across function boundaries, where TypeScript can't infer the refinement. See User-Defined Type Guards.
Variance — Functions are covariant in their return type and contravariant in their parameters, so a handler with a narrower parameter type is not assignable where a wider one is expected (the classic TS2345). Under strictFunctionTypes this is checked only for function-property syntax — method syntax stays bivariant — so declare callback-typed options as properties. The docs demonstrate both by example but never name or generalize the rule.
Type branding — Prevents mixing structurally identical types (UserId vs SessionToken) at zero runtime cost. Use unique symbol for full nominal safety.
declare const _brand: unique symbol;
type UserId = string & { readonly [_brand]: "UserId" };
type SessionToken = string & { readonly [_brand]: "SessionToken" };
function createUserId(id: string): UserId {
return id as UserId;
}
// createUserId(rawToken) → compile error — token ≠ userId
Companion object pattern — Bind the same name to both a type and const value. One import covers annotation and utilities.
as const — Freeze values to literal types, but only when no type exists yet for what you're deriving. If a type already exists, annotate with it instead — see ../rules/favor-existing-types-over-as-const.md for the full rationale and examples.
Escape hatches — as T, !, !: override TypeScript checks. Last resort; frequent use signals refactoring needed. See ../rules/avoid-type-assertions.md for banned patterns and alternatives.
Interface vs Type Alias
Use interface when |
Use type when |
|---|---|
Defining an object shape others will extend or implement |
Unions and intersections |
| You need declaration merging (augmenting third-party types) | Mapped/conditional/utility types |
| Modeling a class contract | Simple semantic alias (type UserId = string) |
// interface — extendable contract
interface Repository<T> {
findById(id: string): T | undefined;
save(entity: T): void;
}
// type — union (can't be done with interface)
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
Why interface extends over type & for composition: TypeScript caches an interface's resolved shape by name, so it doesn't get recomputed each time it's referenced; a type intersection is recomputed at each use site. Interfaces also catch conflicting property types across the merged shapes as a compile error, where an intersection can silently collapse the conflicting property to never. This gap narrows a lot under the Go-ported TypeScript 7 compiler, since the overall baseline gets much faster — but interface extends remains the safer default for composed object shapes; reach for type when you need unions, primitives, tuples, or mapped/conditional types that interface can't express.
Do NOT prefix interfaces with I. See ../rules/no-interface-prefix.md.
Generics: when (not) to use
Don't use generics at a single location — it provides no type safety over any:
// Bad — T appears only in return; equivalent to returning any
declare function parse<T>(name: string): T;
// Good — T appears in both parameter and return (meaningful constraint)
function identity<T>(x: T): T {
return x;
}
function reverse<T>(items: T[]): T[] {
return [...items].reverse();
}
Use descriptive names for multi-parameter generics:
class Dictionary<TKey, TValue> {
get(key: TKey): TValue | undefined { ... }
set(key: TKey, value: TValue): void { ... }
}
Enums best practices
enum Status {
Inactive = 1, // start at 1 — avoids falsy 0 bugs
Active = 2,
Pending = 3,
}
enum DocumentType {
// string enums — better logs and API interop
Passport = "passport",
Visa = "passport_visa",
DriversLicense = "drivers_license",
}
const enum Direction {
Up = "UP",
Down = "DOWN",
} // zero runtime cost (inlined)
Lazy object initialization anti-pattern
Avoid initializing an empty object and adding properties later — TypeScript infers {} and later assignments fail:
// Bad
let config = {};
config.host = "localhost"; // Error: property 'host' does not exist on type '{}'
// Good — initialize all properties together
const config = { host: "localhost", port: 3000 };
// Or annotate first
let config: Config = { host: "localhost", port: 3000 };
Benchmark
Scenario: .benchmarks/scenarios/typescript-001-illegal-states.md · Run: 2026-08-31 · Log: .benchmarks/runs/2026-08-31/typescript-001-illegal-states.json
| Model | Without | With | Delta |
|---|---|---|---|
| claude-opus-4-8 | 67% | 100% | +33% |
| claude-sonnet-4-6 | 67% | 83% | +16% |
| claude-haiku-4-5 | 67% | 83% | +16% |
PASS (run 2026-08-31). Gains on all models — the 2026-06-14 SOFT PASS ceiling effect is partly overcome. Gate per
.agents/skills/skill-optimizer/rules/release-gates.md.
Scenario: .benchmarks/scenarios/typescript-002-state-transitions.md · Run: 2026-08-31 · Log: .benchmarks/runs/2026-08-31/typescript-002-state-transitions.json
| Model | Without | With | Delta |
|---|---|---|---|
| claude-opus-4-8 | 83% | 83% | +0% |
| claude-sonnet-4-6 | 83% | 83% | +0% |
| claude-haiku-4-5 | 67% | 83% | +16% |
SOFT PASS (run 2026-08-31). Haiku +16 (67→83); opus at ceiling. Sonnet's 2026-06-25 N=3 −5% did not reproduce (0 here). Gate per
.agents/skills/skill-optimizer/rules/release-gates.md.