Type Safety
Never as cast
as bypasses the compiler. Every as is a potential runtime crash the compiler can't catch.
// BAD const user = data as User;
// GOOD — validate 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 }
The one exception: a cast immediately following exhaustive validation (as above) is acceptable because the cast is earned. But prefer a type guard or schema library (Zod, Valibot) over manual validation.
Refactoring as out of existing code: When encountering an as cast, determine why TypeScript can't infer the type. Usually one of:
-
Missing discriminant field → add one, use discriminated union
-
Overly wide type (e.g. Record<string, any> ) → narrow the type definition
-
Untyped API boundary → add a type guard or schema parse at the boundary
-
Genuinely impossible to express → use a branded type or satisfies instead
unknown over any
any disables type checking for everything it touches. unknown forces you to narrow before use.
// BAD function handle(input: any) { return input.foo.bar; }
// GOOD function handle(input: unknown) { if (typeof input === "object" && input !== null && "foo" in input) { // narrowed — compiler verifies access } }
When receiving data from external sources (API responses, JSON parse, event payloads, message passing), always type as unknown and narrow.
Discriminated Unions
Model variants with a shared literal discriminant. This makes switch and if narrow automatically.
// BAD — optional fields create ambiguous states type Shape = { kind?: string; radius?: number; width?: number; height?: number };
// GOOD — impossible states are unrepresentable type Shape = | { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
Rules:
-
Discriminant field must be a literal type (string literal, number literal, true /false )
-
Every variant shares the same discriminant field name
-
Each variant's discriminant value is unique
Type Narrowing
Prefer compiler-understood narrowing over manual assertions.
// Narrowing patterns (best → worst):
// 1. Discriminated union switch/if — compiler narrows automatically
// 2. in operator — "key" in obj narrows to variants containing that key
// 3. typeof / instanceof — for primitives and class instances
// 4. User-defined type guard — when above aren't sufficient
// 5. as cast — last resort, only after validation
// in operator narrowing
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
Write type guards when the compiler can't narrow automatically. Return x is T .
function isCircle(s: Shape): s is Shape & { kind: "circle" } { return s.kind === "circle"; }
Rules:
-
The guard body must actually verify the claim — a lying guard is worse than as
-
Prefer discriminated union narrowing over custom guards when possible
-
Name guards isX or hasX for readability
Exhaustiveness Checks
Use never to ensure all variants are handled. The compiler errors if a new variant is added but not handled.
function 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;
throw new Error(unhandled shape: ${(_exhaustive as { kind: string }).kind});
}
}
}
Always add the default: never arm to switches over discriminated unions. When a new variant is added to the union, every switch without a case for it will fail to compile — this is the goal.
For simpler cases, a helper function can reduce boilerplate:
function absurd(x: never, msg?: string): never {
throw new Error(msg ?? unexpected value: ${JSON.stringify(x)});
}
// usage in default arm:
default: return absurd(s, unhandled shape);
satisfies Over as
When you need to verify a value matches a type without widening or narrowing:
// BAD — widens, loses literal types const config = { theme: "dark", cols: 3 } as Config;
// GOOD — validates AND preserves literal types const config = { theme: "dark", cols: 3 } satisfies Config; // config.theme is "dark" (literal), not string
Making Impossible States Unrepresentable
Design types so invalid states cannot be constructed:
// BAD — can be { loading: true, data: User, error: Error } simultaneously type State = { loading: boolean; data?: User; error?: Error };
// GOOD — exactly one state at a time type State = | { status: "idle" } | { status: "loading" } | { status: "success"; data: User } | { status: "error"; error: Error };
If a bug requires checking "wait, can this combination actually happen?" — the type is too loose. Tighten it so the type system answers that question at compile time.