Skip to content

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.

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 anything

Response.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.

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.

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; // User
const config: Config = JSON.parse(text); // `any` again
const config: unknown = JSON.parse(text); // honest

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.

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;
}

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; // narrowed

The compiler forces the caller to check ok before touching value. Exceptions carry no such obligation.

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')); // fine
getUser('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 string
const path = `/users/${id}`; // fine

This is also the pattern for “validated” values: Email, PositiveInt, SanitisedHtml, AbsolutePath. The type says the check has already happened.

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
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 lie

Where 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;
const el = document.getElementById('root')!; // HTMLElement, not HTMLElement | null
map.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.

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.

Very rarely, and always locally. The decision tree:

  1. Can you express the type? Do that.
  2. Is it external data or genuinely unconstrained? unknown plus a guard.
  3. Is it a generic that flows through? A type parameter.
  4. Is it a broken third-party type you’ll fix later? any with 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 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; // fine

Parameter 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 Cat

Method-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; // allowed
animals.push(new Cat()); // type-checks!
dogs[1].bark(); // runtime error — it's a Cat

readonly 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 }

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 null
if (!el) throw new Error('#root not found');
el.focus(); // best: fails loudly, narrows cleanly

Element 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.

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.

  • 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 unknown to any, 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.
  • res.json() and JSON.parse return any; type them unknown and validate.
  • catch gives unknown under strict — narrow with instanceof Error or normalise.
  • Branded types add nominal distinctions at zero runtime cost.
  • as and ! assert without checking; guards and assertion functions actually verify.
  • any spreads and disables checking; unknown contains 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.