TypeScript Type Guards
Runtime type guards, narrowing, and exhaustiveness checking for TypeScript — typeof/instanceof/in guards, custom and generic type predicates (value is T), discriminated union narrowing, assertion functions (asserts value is T), branded/nominal types validated at runtime, and exhaustive switch statements with a never guard. Use when replacing an unsafe `as` assertion with a real runtime check, fixing "Object is possibly undefined", modeling impossible states out of existence, writing an `is*` predicate, or making a switch over a union fail to compile when a case is missing.
npx skills add github:pantheon-org/tekhne --skill development--typescript-advanced--type-guardsRuntime type guards and narrowing: the compile-time type system backed by an actual runtime check, so a type guarantee is real rather than asserted.
Mindset
A type guard is a promise the compiler will hold you to only if the runtime check underneath it is honest. Never reach for any or an unchecked as assertion as a shortcut past a type error — both disable the exact safety net a guard exists to provide, and the error they suppress almost always points at a real design gap. Prefer narrowing a value before use over asserting its type after the fact: if (typeof x !== "string") throw ... gives the compiler a real fact to reason from; x as string gives it nothing but your word. Verify a narrowing actually narrows by re-running tsc --noEmit after the change — a guard that looks correct can still fail to narrow if its predicate doesn’t match the shape TypeScript expects. Every union that models a real set of states deserves an exhaustive switch with a never-typed default branch, so adding a new state without updating every handler is a compile error, not a silent gap.
When to Apply
Use this skill when:
- Replacing an unsafe
asassertion with a runtime check that narrows for real - Writing a custom type predicate (
function is*(x): x is T) - Modeling impossible field combinations out of existence with a discriminated union
- Adding an assertion function (
asserts value is T) to validate and narrow in one call - Creating branded/nominal types with a validating factory function
- Adding exhaustiveness checking to a switch over a union so new variants can’t be silently unhandled
- Fixing “Object is possibly ‘undefined’” without reaching for a non-null assertion (
!)
Use When
- “How do I fix this TypeScript compile error without an
asassertion?” - “How do I check the shape of an
unknownvalue at runtime?” - “How do I make my switch statement error if I add a new case and forget to handle it?”
- “How do I stop two IDs of the same primitive type from being mixed up?”
Scope
In Scope
typeof,instanceof,in, truthiness, and equality narrowing.- Custom and generic type predicate functions (
value is T). - Discriminated union narrowing and exhaustiveness checking with
never. - Assertion functions (
asserts condition,asserts value is T). - Branded/nominal types validated by a runtime type guard.
Out of Scope
- The type-system mechanics behind a union or conditional type itself — see the sibling
typescript-type-systemskill. - Zod-based schema validation as a library choice — see
runtime-validation.mdin the siblingtypescript-practicesskill; this skill covers hand-written predicates and assertion functions. - General design patterns (builder, state machine, event emitter) that use guards — see the sibling
typescript-design-patternsskill.
When NOT to Use
Do not use this skill for compiler configuration (tsconfig, strict-mode flags — see typescript-compiler-config) or for utility-type mechanics like Pick/Omit/ReturnType (see typescript-utility-types). Do not use it as a substitute for actually running tsc --noEmit — the reference files teach the pattern, but the compiler is the source of truth on whether a guard actually narrows.
Quick Commands
Type Check
npx tsc --noEmit
Find Unsafe Patterns
rg -n "\\bany\\b|@ts-ignore| as " src
Quick Fixes
“Object is possibly ‘undefined’”
// BAD — non-null assertion hides the real problem
function getHost(config: Config): string {
return config.host!.toUpperCase();
}
// GOOD — guard before access
function getHost(config: Config): string {
if (config.host === undefined) throw new Error("host is required");
return config.host.toUpperCase();
}
“Type X is not assignable to type Y”
// BAD — forces an incompatible assignment
const id = getValue() as string;
// GOOD — narrow first, then assign
const raw = getValue();
if (typeof raw !== "string") throw new TypeError("Expected string");
const id = raw; // TypeScript now knows id: string
Anti-Patterns
NEVER use any as a default escape hatch
WHY: any disables type checking and hides the design bug the error was pointing at.
BAD:
function process(data: any) {
return data.value;
}
GOOD:
function process<T extends { value: unknown }>(data: T) {
return data.value;
}
NEVER silence type errors with unchecked assertions
WHY: an as assertion bypasses compiler safety without a matching runtime guarantee.
BAD:
const id = input as string;
GOOD:
if (typeof input !== "string") throw new TypeError("Expected string");
const id = input;
NEVER use numeric enum for a discriminated union tag
WHY: numeric enums generate a reverse mapping at runtime (Direction[0] === "Up"), inflate bundle size, and let any number be assigned where the enum type is expected — none of which a string-literal union has.
BAD:
enum Status { Pending, Active, Closed }
function handle(status: Status) { /* Status.Pending accepts any number */ }
GOOD:
type Status = "pending" | "active" | "closed";
function handle(status: Status) { /* only the three literal strings are valid */ }
NEVER let a discriminant literal widen
WHY: let and object-literal inference widen "success" to string, which breaks exhaustive narrowing on the union’s discriminant even though the runtime value never changes.
BAD:
let kind = "success"; // inferred as string, not "success"
const result = { kind, data: 42 }; // result.kind: string — narrowing fails
GOOD:
const kind = "success" as const;
const result = { kind, data: 42 } as const; // result.kind: "success"
NEVER leave a switch over a union without an exhaustiveness check
WHY: without a never-typed default branch, adding a new union variant compiles silently and the new case is unhandled at runtime.
BAD:
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.size ** 2;
// no default — adding "triangle" to Shape compiles without warning
}
}
GOOD:
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.size ** 2;
default:
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
References
| File | Covers |
|---|---|
references/basic-guards.md |
typeof, instanceof, in, truthiness, and equality narrowing |
references/generic-guards.md |
Reusable generic type predicates (isArrayOf, hasProperty, isInstance) |
references/discriminated-unions.md |
Tagged unions, multiple discriminants, state-machine narrowing |
references/exhaustiveness-checking.md |
The never exhaustiveness pattern across switch, if/else, and pattern matching |
references/assertion-functions.md |
asserts condition and asserts value is T functions |
references/branded-types-guards.md |
Nominal types validated by a runtime predicate, multi-level brands, unwrap utilities |
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
No eval scenarios available.
Assertion Functions
Overview
Assertion functions use the asserts keyword to narrow types by throwing errors when conditions fail, rather than returning boolean values.
Basic Assertion Function
function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
const value: string | null = getSomeValue();
assert(value !== null, "Value must be non-null");
// value is narrowed to string after this line
console.log(value.toUpperCase());
Assertion with Type Predicate
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
const data: unknown = "hello";
assertIsString(data);
// data is narrowed to string
console.log(data.toUpperCase());
Assertion for Object Shape
interface User {
id: number;
name: string;
email: string;
}
function assertIsUser(value: unknown): asserts value is User {
if (
typeof value !== "object" ||
value === null ||
!("id" in value) ||
!("name" in value) ||
!("email" in value)
) {
throw new TypeError("Invalid user object");
}
}
const data: unknown = { id: 1, name: "Alice", email: "alice@example.com" };
assertIsUser(data);
// data is narrowed to User
console.log(data.email);
Assertion for Non-null
function assertDefined<T>(
value: T,
message?: string
): asserts value is NonNullable<T> {
if (value === undefined || value === null) {
throw new Error(message || "Value must be defined");
}
}
const config: { apiKey?: string } = getConfig();
assertDefined(config.apiKey, "API key is required");
// config.apiKey is narrowed to string
makeApiCall(config.apiKey);
Assertion for Array Elements
function assertAllStrings(value: unknown[]): asserts value is string[] {
if (!value.every(item => typeof item === "string")) {
throw new TypeError("All elements must be strings");
}
}
const data: unknown[] = ["a", "b", "c"];
assertAllStrings(data);
// data is narrowed to string[]
data.forEach(s => console.log(s.toUpperCase()));
Assertion with Custom Error
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
function assertValidEmail(value: string): asserts value is string {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new ValidationError(`Invalid email: ${value}`);
}
}
const email: string = "user@example.com";
assertValidEmail(email);
// email is validated and remains string type
sendEmail(email);
Best Practices
- Use assertion functions at API boundaries
- Throw descriptive error messages
- Consider custom error classes for categorization
- Use assertions for invariants that must hold
- Document why assertions are necessary
- Prefer type guards for conditional logic
Common Pitfalls
- Assertions terminate execution on failure (not for optional validation)
- Don’t use assertions for expected error conditions
- Forgetting to handle errors thrown by assertions
- Over-using assertions when type guards are more appropriate
- Assertions can’t narrow discriminated unions effectively
Basic Type Guards
Overview
Type guards are TypeScript constructs that allow narrowing types within conditional blocks using runtime checks.
typeof Type Guards
function processValue(value: string | number) {
if (typeof value === "string") {
// value is narrowed to string
return value.toUpperCase();
}
// value is narrowed to number
return value.toFixed(2);
}
instanceof Type Guards
class Dog {
bark() { console.log("Woof!"); }
}
class Cat {
meow() { console.log("Meow!"); }
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark();
} else {
animal.meow();
}
}
in Operator Type Guards
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim();
} else {
animal.fly();
}
}
Truthiness Narrowing
function printLength(str: string | null) {
if (str) {
// str is narrowed to string
console.log(str.length);
} else {
// str is null
console.log("No string provided");
}
}
Equality Narrowing
function example(x: string | number, y: string | boolean) {
if (x === y) {
// x and y are both narrowed to string
x.toUpperCase();
y.toUpperCase();
}
}
Best Practices
- Use typeof for primitives (string, number, boolean, symbol)
- Use instanceof for class instances
- Use in operator for discriminating object shapes
- Combine guards for complex narrowing
- Consider custom type predicates for reusable logic
Common Pitfalls
- typeof null returns “object” (use === null instead)
- typeof array returns “object” (use Array.isArray instead)
- instanceof doesn’t work across realm boundaries
Branded Types Type Guards
Overview
Branded types (nominal types) create distinct types from primitives using unique symbols or phantom types, preventing accidental mixing of semantically different values with the same underlying type.
Basic Branded Type
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, "UserId">;
type ProductId = Brand<number, "ProductId">;
function createUserId(id: number): UserId {
return id as UserId;
}
function createProductId(id: number): ProductId {
return id as ProductId;
}
function getUser(id: UserId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
const userId = createUserId(42);
const productId = createProductId(42);
getUser(userId); // ✓ OK
getUser(productId); // ✗ Error: ProductId not assignable to UserId
Branded Type with Validation
type Email = Brand<string, "Email">;
function isValidEmail(value: string): value is Email {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
function createEmail(value: string): Email {
if (!isValidEmail(value)) {
throw new Error("Invalid email format");
}
return value;
}
function sendEmail(to: Email, subject: string, body: string) {
// Guaranteed to be valid email
console.log(`Sending to ${to}: ${subject}`);
}
const email = createEmail("alice@example.com");
sendEmail(email, "Hello", "...");
// sendEmail("invalid", "Hello", "..."); // ✗ Error: string not assignable to Email
Multiple Brand Levels
type URL = Brand<string, "URL">;
type SecureURL = Brand<URL, "Secure">;
function createURL(value: string): URL {
try {
new globalThis.URL(value);
return value as URL;
} catch {
throw new Error("Invalid URL");
}
}
function createSecureURL(value: URL): SecureURL {
if (!value.startsWith("https://")) {
throw new Error("URL must be secure (https)");
}
return value as SecureURL;
}
function fetchSecure(url: SecureURL) {
// Guaranteed to be valid and secure
return fetch(url);
}
const url = createURL("https://example.com");
const secureUrl = createSecureURL(url);
fetchSecure(secureUrl);
Branded Numeric Types
type PositiveNumber = Brand<number, "Positive">;
type Integer = Brand<number, "Integer">;
type PositiveInteger = Brand<PositiveNumber, "Integer">;
function isPositive(n: number): n is PositiveNumber {
return n > 0;
}
function isInteger(n: number): n is Integer {
return Number.isInteger(n);
}
function createPositiveInteger(n: number): PositiveInteger {
if (!isPositive(n) || !isInteger(n)) {
throw new Error("Must be a positive integer");
}
return n as PositiveInteger;
}
function repeat<T>(item: T, times: PositiveInteger): T[] {
return Array.from({ length: times }, () => item);
}
const count = createPositiveInteger(5);
repeat("hello", count); // ✓ OK
// repeat("hello", 3.5); // ✗ Error: number not assignable to PositiveInteger
Branded Types with Symbol
const UserIdSymbol = Symbol("UserId");
const ProductIdSymbol = Symbol("ProductId");
type UserId = number & { [UserIdSymbol]: true };
type ProductId = number & { [ProductIdSymbol]: true };
function createUserId(id: number): UserId {
return id as UserId;
}
function createProductId(id: number): ProductId {
return id as ProductId;
}
// Symbols ensure brands are truly unique
const userId = createUserId(42);
const productId = createProductId(42);
function getUserName(id: UserId): string {
return `User ${id}`;
}
getUserName(userId); // ✓ OK
// getUserName(productId); // ✗ Error: ProductId not assignable to UserId
Branded Type Utilities
type Brand<K, T> = K & { __brand: T };
type Unbrand<T> = T extends Brand<infer K, any> ? K : T;
type UserId = Brand<number, "UserId">;
type UnbrandedUserId = Unbrand<UserId>; // number
function unwrap<T>(value: Brand<T, any>): T {
return value as T;
}
const userId = 42 as UserId;
const rawId: number = unwrap(userId);
Branded Types in Domain Models
type CustomerId = Brand<string, "CustomerId">;
type OrderId = Brand<string, "OrderId">;
type Money = Brand<number, "Money">;
interface Order {
id: OrderId;
customerId: CustomerId;
total: Money;
}
function createOrder(
customerId: CustomerId,
total: Money
): Order {
return {
id: crypto.randomUUID() as OrderId,
customerId,
total
};
}
function chargeCust omer(id: CustomerId, amount: Money) {
// Type safety ensures correct IDs and amounts
console.log(`Charging customer ${id}: $${amount}`);
}
const customerId = "cust_123" as CustomerId;
const total = 99.99 as Money;
const order = createOrder(customerId, total);
chargeCustomer(order.customerId, order.total); // ✓ OK
// chargeCustomer(order.id, order.total); // ✗ Error: OrderId not assignable to CustomerId
Branded Types with Type Guards
type NonEmptyString = Brand<string, "NonEmpty">;
function isNonEmpty(value: string): value is NonEmptyString {
return value.trim().length > 0;
}
function assertNonEmpty(value: string): asserts value is NonEmptyString {
if (!isNonEmpty(value)) {
throw new Error("String must not be empty");
}
}
function greet(name: NonEmptyString) {
console.log(`Hello, ${name}!`);
}
const input = "Alice";
if (isNonEmpty(input)) {
greet(input); // ✓ OK - narrowed to NonEmptyString
}
const input2 = "Bob";
assertNonEmpty(input2);
greet(input2); // ✓ OK - asserted as NonEmptyString
Best Practices
- Use branded types for domain primitives with semantic meaning
- Combine with validation functions for runtime safety
- Use symbols for truly unique brands
- Document brand semantics in type alias comments
- Create factory functions that validate and brand values
- Use branded types to make illegal states unrepresentable
Common Pitfalls
- Forgetting to validate when creating branded types
- Over-using brands for every primitive (only for semantically distinct types)
- Not providing unwrap utilities when needed
- Using brands without type guards (runtime validation)
- Mixing branded and unbranded values without explicit casting
Discriminated Unions Type Guards
Overview
Discriminated unions (tagged unions) use a common property (discriminant) to enable exhaustive type narrowing and type-safe pattern matching.
Basic Discriminated Union
interface Success {
status: "success";
data: { id: number; name: string };
}
interface Error {
status: "error";
error: { code: number; message: string };
}
interface Loading {
status: "loading";
}
type ApiResponse = Success | Error | Loading;
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "success":
// response is narrowed to Success
console.log(response.data.name);
break;
case "error":
// response is narrowed to Error
console.error(response.error.message);
break;
case "loading":
// response is narrowed to Loading
console.log("Loading...");
break;
}
}
Exhaustiveness Checking
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// Exhaustiveness check: ensures all cases handled
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
Multiple Discriminants
interface NetworkError {
type: "error";
category: "network";
message: string;
}
interface ValidationError {
type: "error";
category: "validation";
fields: string[];
}
interface Success {
type: "success";
data: unknown;
}
type Result = NetworkError | ValidationError | Success;
function handleResult(result: Result) {
if (result.type === "error") {
// result is narrowed to NetworkError | ValidationError
if (result.category === "network") {
// result is narrowed to NetworkError
console.error(result.message);
} else {
// result is narrowed to ValidationError
console.error("Invalid fields:", result.fields);
}
} else {
// result is narrowed to Success
console.log(result.data);
}
}
State Machine with Discriminated Unions
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; startTime: number }
| { status: "connected"; socket: WebSocket }
| { status: "error"; error: Error; retryCount: number };
function transition(state: ConnectionState, event: string): ConnectionState {
switch (state.status) {
case "disconnected":
if (event === "connect") {
return { status: "connecting", startTime: Date.now() };
}
return state;
case "connecting":
if (event === "success") {
return { status: "connected", socket: new WebSocket("ws://...") };
}
if (event === "error") {
return { status: "error", error: new Error("Failed"), retryCount: 0 };
}
return state;
case "connected":
if (event === "disconnect") {
state.socket.close();
return { status: "disconnected" };
}
return state;
case "error":
if (event === "retry" && state.retryCount < 3) {
return { status: "connecting", startTime: Date.now() };
}
return state;
}
}
Discriminated Union Helper
function isOfType<T extends { type: string }, K extends T["type"]>(
item: T,
type: K
): item is Extract<T, { type: K }> {
return item.type === type;
}
type Action =
| { type: "add"; value: number }
| { type: "subtract"; value: number }
| { type: "reset" };
function handle(action: Action) {
if (isOfType(action, "add")) {
// action is narrowed to { type: "add"; value: number }
console.log(`Adding ${action.value}`);
}
}
Nested Discriminated Unions
type ApiCall =
| {
method: "GET";
params: { id: string };
}
| {
method: "POST";
body: { name: string; age: number };
}
| {
method: "PUT";
params: { id: string };
body: { name: string };
};
function makeRequest(call: ApiCall) {
switch (call.method) {
case "GET":
// call has params, no body
fetch(`/api/resource/${call.params.id}`);
break;
case "POST":
// call has body, no params
fetch("/api/resource", {
method: "POST",
body: JSON.stringify(call.body)
});
break;
case "PUT":
// call has both params and body
fetch(`/api/resource/${call.params.id}`, {
method: "PUT",
body: JSON.stringify(call.body)
});
break;
}
}
Best Practices
- Use string literal types for discriminants (not enums)
- Place discriminant property first for clarity
- Always include exhaustiveness checking with never
- Keep discriminant property names consistent across unions
- Use descriptive discriminant values
- Prefer switch over if/else for clarity
Common Pitfalls
- Using non-literal types as discriminants
- Forgetting exhaustiveness check when adding new variants
- Overlapping discriminants across union members
- Using optional discriminants (must be required)
- Not narrowing before accessing variant-specific properties
Exhaustiveness Checking
Overview
Exhaustiveness checking ensures all possible cases in a union type are handled, preventing runtime errors from unhandled cases.
Basic Exhaustiveness with never
type Status = "pending" | "success" | "error";
function handleStatus(status: Status): string {
switch (status) {
case "pending":
return "Loading...";
case "success":
return "Done!";
case "error":
return "Failed!";
default:
// If all cases are handled, status is never here
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
Exhaustiveness with Discriminated Unions
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
default:
const _exhaustive: never = shape;
throw new Error(`Unhandled shape kind: ${_exhaustive}`);
}
}
// If we add a new shape type:
// type Shape = ... | { kind: "triangle"; base: number; height: number };
// TypeScript will error at the _exhaustive assignment, forcing us to handle it
Exhaustiveness Helper Function
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "increment":
return state + 1;
case "decrement":
return state - 1;
case "reset":
return action.value;
default:
return assertNever(action);
}
}
Exhaustiveness with if/else
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) {
return result.value;
} else if (!result.ok) {
throw result.error;
} else {
// This branch is never reachable if all cases are handled
const _exhaustive: never = result;
throw new Error(`Unhandled result: ${_exhaustive}`);
}
}
Exhaustiveness in Pattern Matching
type Option<T> = { _tag: "Some"; value: T } | { _tag: "None" };
function match<T, R>(
option: Option<T>,
patterns: {
Some: (value: T) => R;
None: () => R;
}
): R {
switch (option._tag) {
case "Some":
return patterns.Some(option.value);
case "None":
return patterns.None();
default:
const _exhaustive: never = option;
throw new Error(`Unhandled option: ${_exhaustive}`);
}
}
const result = match({ _tag: "Some", value: 42 }, {
Some: (v) => `Got ${v}`,
None: () => "Nothing"
});
Exhaustiveness in Array Methods
type EventType = "click" | "keypress" | "scroll";
const events: EventType[] = ["click", "keypress", "scroll"];
function getEventHandler(event: EventType): () => void {
switch (event) {
case "click":
return () => console.log("Clicked");
case "keypress":
return () => console.log("Key pressed");
case "scroll":
return () => console.log("Scrolled");
default:
const _exhaustive: never = event;
throw new Error(`No handler for event: ${_exhaustive}`);
}
}
events.forEach(event => {
const handler = getEventHandler(event);
handler();
});
Exhaustiveness with Enums
enum Direction {
North,
South,
East,
West
}
function move(direction: Direction): { x: number; y: number } {
switch (direction) {
case Direction.North:
return { x: 0, y: 1 };
case Direction.South:
return { x: 0, y: -1 };
case Direction.East:
return { x: 1, y: 0 };
case Direction.West:
return { x: -1, y: 0 };
default:
const _exhaustive: never = direction;
throw new Error(`Unhandled direction: ${_exhaustive}`);
}
}
Exhaustiveness in Async Handlers
type AsyncResult<T> =
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
async function handleAsyncResult<T>(result: AsyncResult<T>): Promise<string> {
switch (result.status) {
case "loading":
return "Loading...";
case "success":
return `Success: ${JSON.stringify(result.data)}`;
case "error":
return `Error: ${result.error.message}`;
default:
const _exhaustive: never = result;
throw new Error(`Unhandled async result: ${_exhaustive}`);
}
}
Compile-Time Only Exhaustiveness
// For cases where throwing is undesirable
function assertExhaustive(value: never): void {
// Intentionally empty - only for compile-time checking
}
type Theme = "light" | "dark" | "auto";
function applyTheme(theme: Theme) {
switch (theme) {
case "light":
document.body.className = "light";
break;
case "dark":
document.body.className = "dark";
break;
case "auto":
document.body.className = "auto";
break;
default:
// Compile-time error if cases are missing, no runtime cost
assertExhaustive(theme);
}
}
Exhaustiveness with Return
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
function isIdempotent(method: HttpMethod): boolean {
switch (method) {
case "GET":
case "PUT":
case "DELETE":
return true;
case "POST":
return false;
default:
const _exhaustive: never = method;
throw new Error(`Unknown method: ${_exhaustive}`);
}
}
Best Practices
- Always include exhaustiveness check in switch default
- Use a helper function like assertNever for consistency
- Place exhaustiveness check in default/else branches
- Use descriptive error messages including the value
- Leverage exhaustiveness to catch missing cases during refactoring
- Combine with discriminated unions for type-safe pattern matching
Common Pitfalls
- Forgetting default case (exhaustiveness not enforced)
- Using any or unknown instead of never (breaks exhaustiveness)
- Returning undefined in default instead of throwing (masks bugs)
- Not including exhaustiveness check in if/else chains
- Catching exhaustiveness errors and continuing (defeats purpose)
Generic Type Guards
Overview
Generic type guards provide reusable, type-safe narrowing functions that work across different types.
Basic Generic Type Predicate
function isArray<T>(value: T | T[]): value is T[] {
return Array.isArray(value);
}
const value: string | string[] = getSomeValue();
if (isArray(value)) {
// value is narrowed to string[]
value.map(s => s.toUpperCase());
}
Generic Property Check
function hasProperty<T, K extends string>(
obj: T,
key: K
): obj is T & Record<K, unknown> {
return typeof obj === "object" && obj !== null && key in obj;
}
const data: unknown = { name: "Alice", age: 30 };
if (hasProperty(data, "name")) {
// data is narrowed to have name property
console.log(data.name);
}
Generic Instance Check
function isInstance<T>(
value: unknown,
constructor: new (...args: any[]) => T
): value is T {
return value instanceof constructor;
}
const val: unknown = new Date();
if (isInstance(val, Date)) {
// val is narrowed to Date
console.log(val.getTime());
}
Generic Array Element Check
function isArrayOf<T>(
value: unknown,
guard: (item: unknown) => item is T
): value is T[] {
return Array.isArray(value) && value.every(guard);
}
function isString(value: unknown): value is string {
return typeof value === "string";
}
const data: unknown = ["a", "b", "c"];
if (isArrayOf(data, isString)) {
// data is narrowed to string[]
data.forEach(s => console.log(s.toUpperCase()));
}
Generic Optional Value Check
function isDefined<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}
const values = [1, undefined, 2, null, 3];
const defined = values.filter(isDefined); // number[]
Generic Type Match
type TypeMap = {
string: string;
number: number;
boolean: boolean;
};
function isType<K extends keyof TypeMap>(
value: unknown,
type: K
): value is TypeMap[K] {
return typeof value === type;
}
const val: unknown = "hello";
if (isType(val, "string")) {
// val is narrowed to string
console.log(val.toUpperCase());
}
Best Practices
- Use descriptive generic parameter names
- Constrain generics when possible for better type safety
- Combine generic guards for complex validation
- Consider performance for hot paths
- Document expected guard behavior
Common Pitfalls
- Over-constraining generics limits reusability
- Forgetting to check for null/undefined in object guards
- Runtime checks must match type predicate guarantees
- Generic guards can’t narrow discriminated unions directly