cheat sheet
Type Guards
User-defined type guards narrow types at runtime using the `is` predicate, assertion functions, generic guards, and class-based patterns. Covers API validation, DOM guards, and when to use Zod.
Type Guards
What it is
Type guards are functions or expressions that narrow a type at runtime. When TypeScript cannot determine a type from built-in checks (typeof, instanceof, in), you can write a user-defined type guard — a function whose return type uses the value is Type predicate syntax. When the function returns true, TypeScript narrows the argument to the specified type within the calling scope.
is predicate
A user-defined type guard is a function that returns value is SomeType:
function isString(val: unknown): val is string {
return typeof val === "string";
}
function processInput(val: unknown) {
if (isString(val)) {
console.log(val.toUpperCase()); // val: string
}
}
The predicate tells TypeScript: "if this function returns true, treat the first argument as SomeType in the calling scope."
More complex examples:
interface Cat { kind: "cat"; meow(): void }
interface Dog { kind: "dog"; bark(): void }
function isCat(animal: Cat | Dog): animal is Cat {
return animal.kind === "cat";
}
function interact(animal: Cat | Dog) {
if (isCat(animal)) {
animal.meow(); // animal: Cat
} else {
animal.bark(); // animal: Dog
}
}
Assertion functions
Assertion functions throw when the condition is false and use the asserts keyword. They are useful for runtime validation at boundaries like function entry points.
function assertDefined<T>(val: T | undefined | null, label = "value"): asserts val is T {
if (val == null) {
throw new Error(`Expected ${label} to be defined, got ${val}`);
}
}
function processUser(user: User | undefined) {
assertDefined(user, "user"); // throws if undefined
console.log(user.name); // user: User — no null check needed after this
}
Assert a boolean condition:
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function divide(a: number, b: number): number {
assert(b !== 0, "Cannot divide by zero");
return a / b;
}
Assert a specific type:
function assertIsString(val: unknown): asserts val is string {
if (typeof val !== "string") {
throw new TypeError(`Expected string, got ${typeof val}`);
}
}
const raw: unknown = JSON.parse('"hello"');
assertIsString(raw);
console.log(raw.toUpperCase()); // raw: string
Class-based type guard with instanceof
Using instanceof as a type guard is built-in, but you can wrap it in a function for reuse and composition:
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = "ValidationError";
}
}
class AuthError extends Error {
constructor(public statusCode: 401 | 403, message: string) {
super(message);
this.name = "AuthError";
}
}
function isValidationError(err: unknown): err is ValidationError {
return err instanceof ValidationError;
}
function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError;
}
async function handleRequest() {
try {
await someOperation();
} catch (err) {
if (isValidationError(err)) {
return { status: 400, field: err.field, message: err.message };
}
if (isAuthError(err)) {
return { status: err.statusCode, message: err.message };
}
throw err; // re-throw unexpected errors
}
}
Generic type guard
A generic type guard delegates the per-element check to a callback:
function isArrayOf<T>(
arr: unknown,
guard: (x: unknown) => x is T
): arr is T[] {
return Array.isArray(arr) && arr.every(guard);
}
function isString(val: unknown): val is string {
return typeof val === "string";
}
function isNumber(val: unknown): val is number {
return typeof val === "number";
}
const raw: unknown = ["a", "b", "c"];
if (isArrayOf(raw, isString)) {
console.log(raw.join(", ")); // raw: string[]
}
// Composing guards for object shapes
function isRecord(val: unknown): val is Record<string, unknown> {
return typeof val === "object" && val !== null && !Array.isArray(val);
}
Discriminated union narrowing with a type guard function
Wrapping a discriminant check inside a named type guard function gives the narrowed branch a descriptive name and keeps call-site conditionals readable. Use this pattern when the same discriminant check is repeated across multiple components or when you want to export the guard as part of a module's public API.
type ApiResult<T> =
| { status: "ok"; data: T }
| { status: "error"; code: number; message: string };
function isOk<T>(result: ApiResult<T>): result is { status: "ok"; data: T } {
return result.status === "ok";
}
function isError<T>(
result: ApiResult<T>
): result is { status: "error"; code: number; message: string } {
return result.status === "error";
}
async function fetchData(): Promise<ApiResult<User[]>> {
const res = await fetch("/api/users");
return res.json();
}
const result = await fetchData();
if (isOk(result)) {
result.data.forEach((u) => console.log(u.name));
} else {
console.error(`[${result.code}] ${result.message}`);
}
as const satisfies guard
Using as const narrows a value to its literal types, and satisfies validates it against a type without widening:
const ROLES = ["admin", "user", "moderator"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "user" | "moderator"
function isRole(val: unknown): val is Role {
return typeof val === "string" && (ROLES as readonly string[]).includes(val);
}
const rawRole: unknown = "admin";
if (isRole(rawRole)) {
console.log(rawRole); // rawRole: "admin" | "user" | "moderator"
}
satisfies lets you validate an object against a type while keeping the precise inferred type:
type Config = { host: string; port: number };
const config = {
host: "localhost",
port: 3000,
} satisfies Config;
// config.host is type "localhost" (literal), not just string
Common patterns
API response validator
A runtime check for an API response that verifies required fields exist:
interface UserResponse {
id: number;
name: string;
email: string;
}
function isUserResponse(val: unknown): val is UserResponse {
if (typeof val !== "object" || val === null) return false;
const obj = val as Record<string, unknown>;
return (
typeof obj["id"] === "number" &&
typeof obj["name"] === "string" &&
typeof obj["email"] === "string"
);
}
async function fetchUser(id: number): Promise<UserResponse> {
const res = await fetch(`/api/users/${id}`);
const data: unknown = await res.json();
if (!isUserResponse(data)) {
throw new Error("Invalid user response shape");
}
return data; // UserResponse
}
DOM element type guard
function isHTMLInputElement(el: Element | null): el is HTMLInputElement {
return el instanceof HTMLInputElement;
}
function isHTMLButtonElement(el: Element | null): el is HTMLButtonElement {
return el instanceof HTMLButtonElement;
}
const el = document.getElementById("submit");
if (isHTMLButtonElement(el)) {
el.disabled = true; // el: HTMLButtonElement
}
const input = document.querySelector(".search-input");
if (isHTMLInputElement(input)) {
console.log(input.value); // input: HTMLInputElement
}
JSON parsing with type safety
function safeJsonParse<T>(
json: string,
guard: (val: unknown) => val is T
): T | null {
try {
const val = JSON.parse(json);
return guard(val) ? val : null;
} catch {
return null;
}
}
interface Settings {
theme: "dark" | "light";
language: string;
}
function isSettings(val: unknown): val is Settings {
if (typeof val !== "object" || val === null) return false;
const obj = val as Record<string, unknown>;
return (
(obj["theme"] === "dark" || obj["theme"] === "light") &&
typeof obj["language"] === "string"
);
}
const rawJson = localStorage.getItem("settings") ?? "{}";
const settings = safeJsonParse(rawJson, isSettings);
if (settings !== null) {
applyTheme(settings.theme); // settings: Settings
}
Zod as a runtime type guard library
Zod is the most widely used schema validation library for TypeScript. Instead of writing manual type guard functions, you define schemas that produce both a TypeScript type and a runtime validator.
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user", "moderator"]),
});
type User = z.infer<typeof UserSchema>; // inferred from schema — no duplication
// parse throws on invalid input
const user = UserSchema.parse(await res.json()); // user: User
// safeParse never throws
const result = UserSchema.safeParse(unknownData);
if (result.success) {
console.log(result.data.name); // result.data: User
} else {
console.error(result.error.flatten());
}
// Use as a type guard
function isUser(val: unknown): val is User {
return UserSchema.safeParse(val).success;
}
For complex validation (nested objects, unions, arrays, coercion, custom error messages), Zod is strongly preferred over hand-written type guards. It also generates better error messages.
Composing type guards
Type guards compose naturally with && and ||:
const isNonNullString = (val: unknown): val is string =>
typeof val === "string" && val.length > 0;
const isPositiveNumber = (val: unknown): val is number =>
typeof val === "number" && val > 0 && Number.isFinite(val);
// Higher-order guard combinator
function nullable<T>(guard: (val: unknown) => val is T) {
return (val: unknown): val is T | null => val === null || guard(val);
}
const isNullableString = nullable(isNonNullString);
Predicate functions — the deep dive
A user-defined type guard is a function whose return type is parameter is T. The semantics: the compiler treats the function as opaque (it does not verify that the body actually checks what the signature says), but trusts the predicate when narrowing at call sites. This is a contract between the function author and the compiler — if your predicate lies, the compiler will silently produce unsound narrowings downstream.
function isString(val: unknown): val is string {
return typeof val === "string";
}
// Liar predicate — compiler trusts it anyway
function lies(val: unknown): val is string {
return true; // always says "yes"
}
const x: unknown = 42;
if (lies(x)) {
console.log(x.toUpperCase()); // crashes at runtime — x is a number
}
The runtime contract: a predicate must return true if and only if the value matches the claimed type. Audit your predicates carefully; treat them like unsafe blocks in Rust.
Predicate scope and this
A predicate's is clause can also apply to this rather than a parameter, useful inside class methods:
class Box<T> {
constructor(private value: T | null) {}
hasValue(): this is Box<T> & { readonly value: T } {
return this.value !== null;
}
get(): T | null {
return this.value;
}
}
const b = new Box<string>("hello");
if (b.hasValue()) {
console.log(b.value.toUpperCase()); // b.value: string (narrowed via this-predicate)
}
This is the pattern jQuery used for chainable methods and the one TypeScript's own AST uses for Node.isStringLiteral() etc.
Predicate against a generic
A predicate can carry a generic to narrow to a parametric type. The compiler infers T from the call site.
function isInstanceOf<T>(ctor: new (...args: never[]) => T, val: unknown): val is T {
return val instanceof ctor;
}
const err: unknown = new Error("boom");
if (isInstanceOf(Error, err)) {
console.log(err.message); // err: Error
}
This is structurally identical to a wrapper around instanceof but composes better when threaded into higher-order helpers (filter, find).
Predicate vs assertion vs return-type
Three ways to "narrow" with a function. Pick the one that fits the calling style.
| Form | Signature | Narrows when... | Call style |
|---|---|---|---|
| Predicate | (x: unknown) => x is T | Returns true | if (isT(x)) { ... } |
| Assertion | (x: unknown) => asserts x is T | Returns without throwing | assertIsT(x); /* x: T below */ |
| Plain return | (x: unknown) => T | null | Result is not null | const v = parseT(x); if (v) ... |
| Throwing parser | (x: unknown) => T (throws) | Returns at all | const v = parseT(x); /* v: T */ |
When to use which:
- Predicate — composing in conditionals, filtering arrays (
xs.filter(isString)narrows tostring[]). - Assertion — top-of-function preconditions, narrowing for the rest of a block, environment-variable checks.
- Plain return — optional values where absence is meaningful, e.g.
findUser(id): User | null. - Throwing parser — boundary validation where invalid input is unrecoverable, e.g. Zod
parse().
Generic deep type guards
Building structurally-correct deep guards for nested objects is verbose by hand. The standard pattern composes small per-field guards into a single per-shape guard.
interface Address {
street: string;
city: string;
zip: string;
}
interface User {
id: number;
name: string;
email: string;
address: Address;
tags: string[];
}
const isString = (v: unknown): v is string => typeof v === "string";
const isNumber = (v: unknown): v is number => typeof v === "number";
const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
function isAddress(v: unknown): v is Address {
if (!isRecord(v)) return false;
return isString(v.street) && isString(v.city) && isString(v.zip);
}
function isUser(v: unknown): v is User {
if (!isRecord(v)) return false;
return (
isNumber(v.id) &&
isString(v.name) &&
isString(v.email) &&
isAddress(v.address) &&
Array.isArray(v.tags) &&
v.tags.every(isString)
);
}
const raw: unknown = JSON.parse(
'{"id":1,"name":"Alice","email":"a@example.com","address":{"street":"1 St","city":"Town","zip":"00000"},"tags":["a","b"]}'
);
if (isUser(raw)) console.log(raw.name);
The verbosity is why most teams reach for Zod (or Valibot, or io-ts) once shapes get nested.
Generic guard combinators
A small set of higher-order helpers covers most composition needs.
type Guard<T> = (val: unknown) => val is T;
const isAny = (_: unknown): _ is unknown => true;
const isNull = (v: unknown): v is null => v === null;
const isUndef = (v: unknown): v is undefined => v === undefined;
const isStringG = (v: unknown): v is string => typeof v === "string";
const isNumberG = (v: unknown): v is number => typeof v === "number" && !Number.isNaN(v);
const isBoolean = (v: unknown): v is boolean => typeof v === "boolean";
function or<A, B>(a: Guard<A>, b: Guard<B>): Guard<A | B> {
return (v): v is A | B => a(v) || b(v);
}
function and<A, B>(a: Guard<A>, b: Guard<B>): Guard<A & B> {
return (v): v is A & B => a(v) && b(v);
}
function not<T>(g: Guard<T>) {
return (v: unknown): boolean => !g(v);
}
function optional<T>(g: Guard<T>): Guard<T | undefined> {
return or(g, isUndef);
}
function arrayOf<T>(g: Guard<T>): Guard<T[]> {
return (v): v is T[] => Array.isArray(v) && v.every(g);
}
function recordOf<T>(g: Guard<T>): Guard<Record<string, T>> {
return (v): v is Record<string, T> =>
typeof v === "object" && v !== null && Object.values(v as object).every(g);
}
const isStringOrNumber = or(isStringG, isNumberG);
const isStringArray = arrayOf(isStringG);
const isOptionalString = optional(isStringG);
This gets you 80% of Zod's guarding power in 30 lines. For runtime validation with detailed error messages, refundable form errors, transformations, async refinements, or schema introspection, jump to Zod.
Type guard vs instanceof vs typeof vs Array.isArray
The built-in narrowing checks cover the most common cases. Use the built-in form when applicable — it is faster, more familiar, and has no extra function call.
| Check | Narrows | When to use |
|---|---|---|
typeof x === "string" | string | Primitive type checks |
typeof x === "object" | object | null | Always pair with null check |
x instanceof Date | Date | Class instances within the same realm |
Array.isArray(x) | unknown[] | Cross-realm-safe array detection |
"prop" in x | { prop: unknown } & X | Variant-discrimination in unions |
| User-defined guard | Anything | When the built-ins don't suffice |
Prefer Array.isArray() over x instanceof Array because the latter fails for arrays created in another realm (iframe, worker). The same applies to typed arrays — use ArrayBuffer.isView(x) instead of instanceof Uint8Array when crossing iframes.
function example(val: unknown) {
if (Array.isArray(val)) {
// val: unknown[] — cross-realm safe
}
if (val instanceof Date) {
// val: Date — fails for Dates from another realm
}
if (typeof val === "object" && val !== null && Symbol.toStringTag in val) {
// For cross-realm Map/Set/etc, check the toStringTag
const tag = (val as { [Symbol.toStringTag]: unknown })[Symbol.toStringTag];
if (tag === "Map") {
// val is a Map (from any realm)
}
}
}
Pairing with runtime validation (Zod)
Hand-written guards work but accumulate bugs over time as types evolve. Zod ties the type and validator together so they cannot drift. The TypeScript type is inferred from the schema, not duplicated.
import { z } from "zod";
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(120),
email: z.string().email(),
role: z.enum(["admin", "user", "moderator"]),
createdAt: z.coerce.date(),
preferences: z.object({
theme: z.enum(["light", "dark", "auto"]),
notifications: z.boolean(),
}).optional(),
});
type User = z.infer<typeof UserSchema>;
// Strict — throws on first error
const u1 = UserSchema.parse(await fetch("/api/users/1").then(r => r.json()));
// Soft — never throws, returns a discriminated union
const result = UserSchema.safeParse(unknownInput);
if (result.success) {
console.log(result.data.name);
} else {
for (const issue of result.error.issues) {
console.error(`${issue.path.join(".")}: ${issue.message}`);
}
}
// Compose into a type-guard if you need one
function isUser(v: unknown): v is User {
return UserSchema.safeParse(v).success;
}
Why Zod over hand-rolled guards:
- No duplication — schema is the single source of truth; type is derived.
- Composable —
.optional(),.refine(),.transform(),.brand(),.array(),.union(),.discriminatedUnion()chain naturally. - Detailed errors — every failure includes a path and message.
- Coercion —
z.coerce.date(),z.coerce.number()handle common input-shape mismatches. - Branded output —
.brand<"Email">()produces a nominal type the rest of the codebase can rely on.
Alternatives in the same ecosystem with similar ergonomics: Valibot (lighter, more modular), io-ts (functional-first, older), ArkType (TypeScript-syntax schemas), Effect Schema (part of Effect-TS).
Type guards inside Array filter
Array.prototype.filter accepts a callback that may be a type guard, and TypeScript narrows the result array accordingly. This is the most ergonomic use of predicates.
const mixed: (string | number | null | undefined)[] = ["a", 1, null, "b", undefined, 2];
const isString = (v: unknown): v is string => typeof v === "string";
const isDefined = <T>(v: T | null | undefined): v is T => v != null;
const strings = mixed.filter(isString);
// strings: string[] — narrowed from (string | number | null | undefined)[]
const nonNull = mixed.filter(isDefined);
// nonNull: (string | number)[] — null and undefined removed
A common bug is to use a bare arrow function that returns a boolean — the array stays as the original type. The predicate is T return type is what triggers the filter narrowing.
// Subtly wrong — strings2 is still (string | number | null | undefined)[]
const strings2 = mixed.filter((v) => typeof v === "string");
// Correct
const strings3 = mixed.filter(isString); // string[]
const strings4 = mixed.filter((v): v is string => typeof v === "string"); // string[]
Type guards for unknown error in catch
Since TS 4.4, catch clauses default to unknown. Narrowing a caught error is the single most-frequently-needed type-guard pattern.
function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
if (
typeof err === "object" &&
err !== null &&
"message" in err &&
typeof (err as { message: unknown }).message === "string"
) {
return (err as { message: string }).message;
}
return String(err);
}
try {
await someAsyncOp();
} catch (err) {
console.error(getErrorMessage(err));
}
A standard utility every codebase needs. Variants for Node code, HTTP status, validation errors etc. follow the same pattern.
Type guards inside React/JSX
Conditional rendering depends on narrowing the children prop or state union. A type guard turns "the compiler doesn't know which branch" into "exhaustively render every variant".
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; message: string };
function isSuccess<T>(s: LoadState<T>): s is { status: "success"; data: T } {
return s.status === "success";
}
function Loader<T>({ state, render }: { state: LoadState<T>; render: (d: T) => unknown }) {
if (state.status === "idle") return "Idle";
if (state.status === "loading") return "Loading…";
if (isSuccess(state)) return render(state.data);
return `Error: ${state.message}`;
}
The inline state.status === "..." checks narrow without a named guard; the named isSuccess form is useful when you need to filter an array or pass the predicate as a callback.
Common pitfalls
- Lying predicates — a
val is Tfunction that doesn't actually verifyTcauses silent unsoundness downstream. Test predicates with property-based tests. - Forgetting
isin the return type —function isString(v: unknown): booleandoes not narrow. Must bev is string. filter((v) => typeof v === "string")not narrowing — without an explicit predicate return type, TypeScript keeps the original array type. Wrap as(v): v is string => ...or use a named guard.val !== null && val !== undefinedwithout typing — write a genericisDefined<T>(v: T | null | undefined): v is T.- Hand-written guards drifting from types — when the type adds a field but the guard does not, the guard is unsound. Either generate guards from types (
ts-auto-guard,typescript-is) or use Zod where the type is derived from the schema. instanceofacross realms — fails for cross-frame/cross-worker objects. Use tag-based checks (Symbol.toStringTag,Array.isArray).inoperator with optional properties —"foo" in objistrueeven whenfoo === undefined. Combine with a value check if you mean "set to something".- Casting inside a guard body —
return (val as User).id !== undefinedshort-circuits the check and accepts wrong shapes. Always check structurally. - Assertion functions with implicit any return —
function assertX(x): asserts x is Xneeds the parameter typed (otherwise TS errors on theassertsclause). assertsnot enforcing throw — the compiler doesn't verify that you throw when the condition fails. Audit assertions like predicates.- Generic type-guard combinators losing inference — sometimes
or(isA, isB)infersunknowninstead ofA | B. Annotate the generic on the call site if inference falters. - Overconfidence in shallow checks —
typeof obj === "object" && obj !== nulldoes not guarantee the shape of the object. Drill into each field you actually use.
Real-world recipes
Recipe 1 — Universal getErrorMessage
Every codebase needs this. Drop-in for catch (err) clauses.
export function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
if (typeof err === "object" && err !== null) {
const obj = err as { message?: unknown; toString?: () => string };
if (typeof obj.message === "string") return obj.message;
if (typeof obj.toString === "function") return obj.toString();
}
return String(err);
}
try {
JSON.parse("not json");
} catch (err) {
console.error(getErrorMessage(err));
}
Output:
Unexpected token 'o', "not json" is not valid JSON
Recipe 2 — Node ENOENT detection
A robust guard that narrows arbitrary unknown errors to the Node "missing file" variety.
import { readFile } from "node:fs/promises";
interface NodeSystemError extends Error {
code: string;
syscall: string;
path?: string;
}
function isNodeSystemError(err: unknown): err is NodeSystemError {
return (
err instanceof Error &&
typeof (err as { code?: unknown }).code === "string" &&
typeof (err as { syscall?: unknown }).syscall === "string"
);
}
async function readOrDefault(path: string, fallback: string): Promise<string> {
try {
return await readFile(path, "utf8");
} catch (err) {
if (isNodeSystemError(err) && err.code === "ENOENT") {
return fallback;
}
throw err;
}
}
Recipe 3 — Zod-powered API client
A fetch wrapper that validates every response against a schema and returns a typed Result. No runtime trust in the server is assumed.
import { z } from "zod";
type Result<T, E> = { ok: true; data: T } | { ok: false; error: E };
async function getJson<T extends z.ZodTypeAny>(
url: string,
schema: T,
): Promise<Result<z.infer<T>, string>> {
try {
const res = await fetch(url);
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
const json: unknown = await res.json();
const parsed = schema.safeParse(json);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues.map(i => i.message).join("; ") };
}
return { ok: true, data: parsed.data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
const User = z.object({ id: z.number(), name: z.string() });
const result = await getJson("https://api.example.com/users/1", User);
if (result.ok) console.log(result.data.name);
else console.error(result.error);
Recipe 4 — Generic deep-equality guard for tuples
A predicate that narrows an unknown value to a tuple of specific element types.
type Guard<T> = (v: unknown) => v is T;
function tupleOf<A, B>(a: Guard<A>, b: Guard<B>): Guard<[A, B]>;
function tupleOf<A, B, C>(a: Guard<A>, b: Guard<B>, c: Guard<C>): Guard<[A, B, C]>;
function tupleOf(...guards: Guard<unknown>[]) {
return (v: unknown): boolean =>
Array.isArray(v) && v.length === guards.length && v.every((x, i) => guards[i](x));
}
const isString = (v: unknown): v is string => typeof v === "string";
const isNumber = (v: unknown): v is number => typeof v === "number";
const isPair = tupleOf(isString, isNumber) as Guard<[string, number]>;
const raw: unknown = ["age", 42];
if (isPair(raw)) {
const [label, value] = raw;
console.log(`${label}: ${value}`);
}
Output:
age: 42
Recipe 5 — Class registry with instanceof guards
A plugin system that registers handlers keyed by class. The registry uses instanceof guards to dispatch.
abstract class Event { }
class ClickEvent extends Event { constructor(public x: number, public y: number) { super() } }
class KeyEvent extends Event { constructor(public key: string) { super() } }
class ResizeEvent extends Event { constructor(public w: number, public h: number) { super() } }
type Handler<E extends Event> = (e: E) => void;
class EventBus {
private handlers = new Map<new (...args: never[]) => Event, Handler<Event>[]>();
on<E extends Event>(ctor: new (...args: never[]) => E, fn: Handler<E>): void {
const list = this.handlers.get(ctor) ?? [];
list.push(fn as Handler<Event>);
this.handlers.set(ctor, list);
}
emit(event: Event): void {
for (const [ctor, list] of this.handlers) {
if (event instanceof ctor) list.forEach(fn => fn(event));
}
}
}
const bus = new EventBus();
bus.on(ClickEvent, e => console.log(`click ${e.x},${e.y}`));
bus.on(KeyEvent, e => console.log(`key ${e.key}`));
bus.emit(new ClickEvent(10, 20));
bus.emit(new KeyEvent("Enter"));
Output:
click 10,20
key Enter
Recipe 6 — Branded type construction with a guard
Brand a string as a validated Email only via the guard. Downstream functions accept Email instead of string, so passing raw input is a compile error.
type Email = string & { readonly __brand: "Email" };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function isEmail(v: unknown): v is Email {
return typeof v === "string" && EMAIL_RE.test(v);
}
function parseEmail(v: unknown): Email {
if (!isEmail(v)) throw new TypeError(`not an email: ${String(v)}`);
return v;
}
function sendInvite(to: Email): void {
console.log(`-> ${to}`);
}
const raw: unknown = "alice@example.com";
sendInvite(parseEmail(raw));
// sendInvite("alice@example.com"); // Error — raw string is not Email
See the branded types article for the full pattern. Guards are the canonical constructors for brands.
Recipe 7 — Discriminated union guard factory
Generate per-variant type guards from a discriminated union automatically. Each guard narrows to a specific variant.
type Msg =
| { kind: "text"; content: string }
| { kind: "image"; url: string; alt: string }
| { kind: "video"; url: string; durationSec: number };
function variantGuard<T extends { kind: string }, K extends T["kind"]>(kind: K) {
return (v: T): v is Extract<T, { kind: K }> => v.kind === kind;
}
const isText = variantGuard<Msg, "text">("text");
const isImage = variantGuard<Msg, "image">("image");
const isVideo = variantGuard<Msg, "video">("video");
const messages: Msg[] = [
{ kind: "text", content: "hi" },
{ kind: "image", url: "/a.png", alt: "a" },
{ kind: "video", url: "/b.mp4", durationSec: 12 },
];
const images = messages.filter(isImage);
console.log(images.map(m => m.url).join(", "));
Output:
/a.png
Recipe 8 — Schema-driven guard cache
A pattern for performance-critical hot paths: pre-compile guards once and reuse them. Useful in JSON-heavy hot loops (parsers, log shippers).
import { z } from "zod";
const schemas = {
user: z.object({ id: z.number(), name: z.string() }),
post: z.object({ id: z.number(), title: z.string() }),
event: z.object({ ts: z.number(), kind: z.string() }),
} as const;
type Kind = keyof typeof schemas;
function isValid<K extends Kind>(kind: K, v: unknown): v is z.infer<(typeof schemas)[K]> {
return schemas[kind].safeParse(v).success;
}
const inputs: unknown[] = [
{ id: 1, name: "Alice" },
{ id: 2, title: "Hello" },
{ ts: Date.now(), kind: "login" },
];
for (const item of inputs) {
if (isValid("user", item)) console.log(`user: ${item.name}`);
if (isValid("post", item)) console.log(`post: ${item.title}`);
if (isValid("event", item)) console.log(`event: ${item.kind}`);
}
Output:
user: Alice
post: Hello
event: login