Patterns and Gotchas
The recipes that come up in real codebases, and the mistakes that produce the errors you’ll actually
see. Everything here assumes strict: true.
Typing fetch and JSON safely
Section titled “Typing fetch and JSON safely”This is the most common way type safety silently disappears from a codebase.
// The mistake almost everyone makes.const res = await fetch('/api/user');const user: User = await res.json();user.email.toLowerCase(); // may crash — nothing checked anythingResponse.json() returns Promise<any>, and any satisfies every annotation. The : User
annotation is a wish, not a check. If the API returns { error: 'not found' }, you find out via a
TypeError in production.
Step one: force yourself to deal with it
Section titled “Step one: force yourself to deal with it”const res = await fetch('/api/user');const data: unknown = await res.json();data.email; // error: 'data' is of type 'unknown'Annotating as unknown converts a silent runtime bug into a compile error you must resolve.
Step two: validate
Section titled “Step two: validate”Hand-written guard, for simple shapes:
interface User { id: number; email: string; displayName?: string;}
function isUser(value: unknown): value is User { if (typeof value !== 'object' || value === null) return false; const v = value as Record<string, unknown>; return ( typeof v.id === 'number' && typeof v.email === 'string' && (v.displayName === undefined || typeof v.displayName === 'string') );}
async function getUser(id: number): Promise<User> { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
const data: unknown = await res.json(); if (!isUser(data)) throw new TypeError('Unexpected /api/users response shape'); return data;}Schema library, for anything real. The key advantage is that the type and the validator are the same declaration, so they can’t drift:
import { z } from 'zod';
const UserSchema = z.object({ id: z.number(), email: z.string().email(), displayName: z.string().optional(),});
type User = z.infer<typeof UserSchema>;// { id: number; email: string; displayName?: string }
async function getUser(id: number): Promise<User> { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); return UserSchema.parse(await res.json()); // throws ZodError on mismatch}Use safeParse when you’d rather branch than throw:
const result = UserSchema.safeParse(await res.json());if (!result.success) { console.error(result.error.issues); return null;}result.data; // UserJSON.parse has the same hole
Section titled “JSON.parse has the same hole”const config: Config = JSON.parse(text); // `any` againconst config: unknown = JSON.parse(text); // honestError handling with unknown
Section titled “Error handling with unknown”With useUnknownInCatchVariables (part of strict), the catch binding is unknown. That’s
correct — JavaScript can throw anything: throw 'oops', throw { code: 500 }, a rejected promise
with a string.
try { await risky();} catch (error) { if (error instanceof Error) { console.error(error.message, error.stack); } else { console.error('Non-Error thrown:', error); }}A normalising helper is worth having:
function toError(value: unknown): Error { if (value instanceof Error) return value; if (typeof value === 'string') return new Error(value); return new Error(JSON.stringify(value), { cause: value });}
try { await risky();} catch (e) { const error = toError(e); logger.error({ message: error.message, stack: error.stack });}Error’s cause option requires target: ES2022 or lib including ES2022.Error.
Custom errors
Section titled “Custom errors”class HttpError extends Error { constructor( readonly status: number, readonly url: string, message?: string, ) { super(message ?? `HTTP ${status} for ${url}`); this.name = 'HttpError'; }}
function isHttpError(e: unknown): e is HttpError { return e instanceof HttpError;}Typed results instead of exceptions
Section titled “Typed results instead of exceptions”For expected failures, a result union is often clearer than throwing:
type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E };
async function tryFetch(url: string): Promise<Result<Response>> { try { return { ok: true, value: await fetch(url) }; } catch (e) { return { ok: false, error: toError(e) }; }}
const r = await tryFetch('/api');if (!r.ok) return handle(r.error);r.value.status; // narrowedThe compiler forces the caller to check ok before touching value. Exceptions carry no such
obligation.
Branded types
Section titled “Branded types”Structural typing means UserId and OrderId are both just string, so they’re freely
interchangeable. Branding restores the distinction with zero runtime cost.
declare const brand: unique symbol;type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, 'UserId'>;type OrderId = Brand<string, 'OrderId'>;type Email = Brand<string, 'Email'>;Values enter the branded type only through a validating constructor:
function toUserId(value: string): UserId { if (!/^usr_[a-z0-9]{8}$/.test(value)) { throw new TypeError(`Invalid user id: ${value}`); } return value as UserId;}
function getUser(id: UserId) { /* ... */ }
getUser(toUserId('usr_a1b2c3d4')); // finegetUser('usr_a1b2c3d4'); // error: Type 'string' is not assignable to type 'UserId'A UserId is still a plain string at runtime — it concatenates, compares, and serialises normally.
The brand exists only in the type system.
const id = toUserId('usr_a1b2c3d4');id.toUpperCase(); // fine — still a stringconst path = `/users/${id}`; // fineThis is also the pattern for “validated” values: Email, PositiveInt, SanitisedHtml,
AbsolutePath. The type says the check has already happened.
Assertions versus guards
Section titled “Assertions versus guards”Three tools that look similar and differ entirely in whether anything is checked.
| Tool | Runtime check | Compile-time effect | Safe? |
|---|---|---|---|
x as T |
None | Forces the type | No — you are asserting |
x! |
None | Removes null/undefined |
No |
isT(x) (x is T) |
Your code | Narrows in the branch | Only if the body is correct |
assertIsT(x) (asserts x is T) |
Your code, throws | Narrows onward | Only if the body is correct |
as — type assertion
Section titled “as — type assertion”const el = document.getElementById('root') as HTMLCanvasElement;el.getContext('2d'); // compiles. Crashes if #root is a <div>.as emits nothing. It is not a cast, not a conversion, not a check — it’s you overruling the
compiler. TypeScript restricts it slightly: you can only assert between types with some overlap.
const n = 'hello' as number;// error: Conversion of type 'string' to type 'number' may be a mistake because// neither type sufficiently overlaps with the other.The escape hatch for that is as unknown as T, which defeats the restriction entirely.
const n = 'hello' as unknown as number; // compiles, and is a lieWhere as is genuinely appropriate:
// 1. You have information the compiler can't: a DOM query you control.const canvas = document.querySelector('#chart') as HTMLCanvasElement;
// 2. Building an object incrementally in a helper you've reasoned about.const acc = {} as Record<string, number[]>;
// 3. Narrowing a literal for inference, rather than widening.const method = 'GET' as const;! — non-null assertion
Section titled “! — non-null assertion”const el = document.getElementById('root')!; // HTMLElement, not HTMLElement | nullmap.get(key)!.push(value);Same story: no runtime check, no emitted code. Prefer an explicit check or an assertion function that actually throws with a useful message:
function required<T>(value: T | null | undefined, name: string): T { if (value == null) throw new Error(`Missing required value: ${name}`); return value;}
const el = required(document.getElementById('root'), '#root');You get the same narrowing plus a real error when the assumption breaks.
Guards — the safe option
Section titled “Guards — the safe option”Covered fully in unions and narrowing. The distinction to internalise: an assertion changes what the compiler believes; a guard changes what the compiler believes and verifies it at runtime. Reach for the guard.
When any is acceptable
Section titled “When any is acceptable”Very rarely, and always locally. The decision tree:
- Can you express the type? Do that.
- Is it external data or genuinely unconstrained?
unknownplus a guard. - Is it a generic that flows through? A type parameter.
- Is it a broken third-party type you’ll fix later?
anywith a comment and a TODO.
// Acceptable: a constraint position where the value is never inspected.function withRetry<F extends (...args: any[]) => Promise<any>>(fn: F): F { /* ... */ }(...args: any[]) => any as a constraint is idiomatic — the constraint says “some function”, and
the actual types come from F. That’s different from typing a value as any.
// Not acceptable: any as an actual value type.function handle(payload: any) { payload.whatever.deeply.nested; // no help, no safety}Enable @typescript-eslint’s no-explicit-any as a warning to keep them visible and countable.
Variance intuition
Section titled “Variance intuition”Variance is about when a container of Dog is usable as a container of Animal. You don’t need
the theory, but you need the intuition, because the error messages assume it.
Given Dog extends Animal:
Return types are covariant. A function returning Dog works anywhere a function returning
Animal is expected — the caller asked for an Animal and got something that is one.
type MakeAnimal = () => Animal;const makeDog: () => Dog = () => new Dog();const f: MakeAnimal = makeDog; // fineParameter types are contravariant. A function accepting Animal works where a function
accepting Dog is expected — it can handle anything a Dog handler could, and more.
type HandleDog = (d: Dog) => void;const handleAnimal = (a: Animal) => {};const g: HandleDog = handleAnimal; // fine
const handleDog = (d: Dog) => d.bark();const h: (a: Animal) => void = handleDog;// error under strictFunctionTypes — h could be called with a CatMethod-shorthand parameters are bivariant — deliberately unsound, so that
Array<Dog> is assignable to Array<Animal>.
interface Handler { handle(x: Dog): void; // bivariant: strictFunctionTypes does not apply handle2: (x: Dog) => void; // contravariant: checked strictly}Mutable arrays are covariant and unsound. TypeScript allows this because forbidding it would break enormous amounts of practical code:
const dogs: Dog[] = [new Dog()];const animals: Animal[] = dogs; // allowedanimals.push(new Cat()); // type-checks!dogs[1].bark(); // runtime error — it's a Catreadonly Animal[] is safely covariant, because you can’t push into it. Prefer readonly T[] for
parameters you don’t mutate — it’s both safer and more permissive for callers.
Since TypeScript 4.7 you can annotate variance explicitly on type parameters with in (contravariant),
out (covariant), and in out (invariant). This is a compiler performance hint and a correctness
assertion, mostly relevant to library authors:
interface Producer<out T> { get(): T }interface Consumer<in T> { accept(value: T): void }Common errors and their fixes
Section titled “Common errors and their fixes”Type 'X' is not assignable to type 'Y' (TS2322)
Section titled “Type 'X' is not assignable to type 'Y' (TS2322)”The catch-all. Read the last line of the error — TypeScript nests the explanation, and the innermost message names the real mismatch.
type Status = 'active' | 'inactive';let s: Status = 'activee';// Type '"activee"' is not assignable to type 'Status'.Usual causes: a typo, a widened literal (let instead of const, or a property in a mutable
object), or undefined sneaking in from an optional property.
Property 'x' does not exist on type 'Y' (TS2339)
Section titled “Property 'x' does not exist on type 'Y' (TS2339)”Either the property genuinely isn’t there, or you’re on a union and haven’t narrowed.
function f(x: string | string[]) { x.push('a'); // Property 'push' does not exist on type 'string | string[]'}// Fix: narrow.function f(x: string | string[]) { if (Array.isArray(x)) x.push('a');}Parameter 'x' implicitly has an 'any' type (TS7006)
Section titled “Parameter 'x' implicitly has an 'any' type (TS7006)”Annotate the parameter. If it’s a callback that should be contextually typed, the surrounding type is probably missing or wrong.
Object is possibly 'null' / 'undefined' (TS18047 / TS18048)
Section titled “Object is possibly 'null' / 'undefined' (TS18047 / TS18048)”strictNullChecks doing its job. Narrow it, use ?./??, or (last resort) !.
const el = document.getElementById('root');el.focus(); // 'el' is possibly 'null'el?.focus(); // silently does nothing if nullif (!el) throw new Error('#root not found');el.focus(); // best: fails loudly, narrows cleanlyElement implicitly has an 'any' type because expression of type 'string' can't be used to index type 'X' (TS7053)
Section titled “Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'X' (TS7053)”You’re indexing an object with a dynamic string key.
const config = { host: 'localhost', port: 8080 };function get(key: string) { return config[key]; // TS7053}
// Fix 1: constrain the key.function get<K extends keyof typeof config>(key: K) { return config[key];}
// Fix 2: give the object an index signature.const config: Record<string, string | number> = { host: 'localhost', port: 8080 };Argument of type 'X' is not assignable to parameter of type 'Y' (TS2345)
Section titled “Argument of type 'X' is not assignable to parameter of type 'Y' (TS2345)”TS2322 in argument position. Same causes; check literal widening first.
Object literal may only specify known properties
Section titled “Object literal may only specify known properties”The excess property check on a fresh literal. Either you have a typo, or the extra property is real and the type needs widening. See functions and objects.
'e' is of type 'unknown' (TS18046)
Section titled “'e' is of type 'unknown' (TS18046)”A catch variable, or a value you typed unknown. Narrow with instanceof or a guard.
This comparison appears to be unintentional because the types have no overlap (TS2367)
Section titled “This comparison appears to be unintentional because the types have no overlap (TS2367)”Usually a stale literal union member or a typo in a comparison string. Occasionally it means you already narrowed the value earlier and forgot.
Type instantiation is excessively deep and possibly infinite (TS2589)
Section titled “Type instantiation is excessively deep and possibly infinite (TS2589)”A recursive conditional or mapped type went too far. Reduce the recursion, add a depth counter parameter, or simplify the type. Deeply recursive types also make editors sluggish.
Types have separate declarations of a private property 'x'
Section titled “Types have separate declarations of a private property 'x'”Two structurally identical classes with private members. Extract an interface and type against
that instead of the class.
A few habits that prevent most of this
Section titled “A few habits that prevent most of this”- Annotate boundaries, infer interiors. Function signatures and exported values get types; local variables usually don’t need them.
- Parse at the edge. Every byte from the network, disk, or user gets validated once, then flows as a trusted type.
- Prefer
unknowntoany, guards to assertions, and narrowing to!. - Model states as discriminated unions, so impossible combinations can’t be constructed.
- Make illegal values unrepresentable — branded types, literal unions, required fields — rather than checking for them everywhere.
- Read the innermost line of a long error. The outer lines are context; the last one is the cause.
Key points
Section titled “Key points”res.json()andJSON.parsereturnany; type themunknownand validate.catchgivesunknownunderstrict— narrow withinstanceof Erroror normalise.- Branded types add nominal distinctions at zero runtime cost.
asand!assert without checking; guards and assertion functions actually verify.anyspreads and disables checking;unknowncontains the problem.- Function returns are covariant, parameters contravariant (methods bivariant), and mutable arrays
are covariant-but-unsound — use
readonly T[]where you can. - Most errors trace back to literal widening, a missing narrow, or unvalidated external data.