Type Narrowing Without the Boilerplate
TypeScript knows more about your types inside a conditional block than outside it. Type guards are the mechanism that makes this work โ they teach the compiler that after a certain check, a value can only be one specific type. The built-in ones (typeof, instanceof, in) cover most cases. Custom type predicates and discriminated unions handle the rest.
- You write
as SomeTypeto silence the compiler instead of narrowing properly - You have runtime checks that TypeScript doesn't respect because you didn't use a type predicate
- Your discriminated unions fall through to
neverin switch statements and you're not sure why - You're repeating the same
if (typeof x === 'string')checks everywhere instead of extracting a guard
Discriminated unions + exhaustive switch statements eliminate entire categories of runtime errors that type assertions only hide.
Built-in Narrowing
typeof
function format(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.toUpperCase(); // TypeScript knows: value is string here
}
if (typeof value === 'number') {
return value.toFixed(2); // value is number here
}
return String(value); // value is boolean here
}
// typeof only narrows to JS primitives:
// 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'function' | 'object'
// Note: typeof null === 'object' โ always check null separatelyinstanceof
class NetworkError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'NetworkError';
}
}
class ValidationError extends Error {
constructor(public field: string, message: string) {
super(message);
this.name = 'ValidationError';
}
}
function handleError(error: unknown): string {
if (error instanceof NetworkError) {
return `Network ${error.status}: ${error.message}`;
}
if (error instanceof ValidationError) {
return `Validation on '${error.field}': ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return 'Unknown error';
}in operator
interface Cat { meow(): void; }
interface Dog { bark(): void; }
function makeSound(animal: Cat | Dog): void {
if ('meow' in animal) {
animal.meow(); // animal is Cat
} else {
animal.bark(); // animal is Dog
}
}
// Useful for optional properties too
interface Config {
timeout?: number;
endpoint: string;
}
function logConfig(config: Config) {
if ('timeout' in config && config.timeout !== undefined) {
console.log('Timeout:', config.timeout); // number, not number | undefined
}
}Discriminated Unions: The Most Powerful Pattern
A discriminated union is a union type where every member has a common literal property (the discriminant). TypeScript uses this to narrow in switch statements with total exhaustiveness checking.
flowchart TD
result["Result: Success | Failure | Pending"]
result -->|"status === 'success'"| s["data: T is available"]
result -->|"status === 'failure'"| f["error: string is available"]
result -->|"status === 'pending'"| p["Neither โ still loading"]
style s fill:#16a34a,color:#fff,stroke:#15803d
style f fill:#dc2626,color:#fff,stroke:#b91c1c
style p fill:#0078D4,color:#fff,stroke:#005a9e
type Result<T> =
| { status: 'success'; data: T }
| { status: 'failure'; error: string; code: number }
| { status: 'pending' };
function processResult<T>(result: Result<T>): string {
switch (result.status) {
case 'success':
return JSON.stringify(result.data); // data is T โ no assertion needed
case 'failure':
return `Error ${result.code}: ${result.error}`; // error and code available
case 'pending':
return 'Loading...';
default:
// Exhaustiveness check โ TypeScript catches missing cases
const _never: never = result;
throw new Error(`Unhandled status: ${String(_never)}`);
}
}Custom Type Predicates
When built-in narrowing isn't enough, write a function that returns value is Type โ a type predicate. TypeScript trusts your runtime check and narrows accordingly.
// Type predicate: tells TS "if this returns true, value is User"
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof (value as any).id === 'string' &&
'name' in value &&
typeof (value as any).name === 'string'
);
}
// Usage
const data: unknown = await fetchData();
if (isUser(data)) {
console.log(data.name); // โ
TypeScript knows data is User
} else {
console.log('Not a user');
}
// Filter arrays to narrow types
const maybeUsers: unknown[] = getItems();
const users: User[] = maybeUsers.filter(isUser); // filter narrows the array typeAssertion Functions (TypeScript 3.7+)
Assertion functions throw on failure โ they're useful in constructors and functions where you want to narrow the type for the rest of the current scope.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeError(`Expected string, got ${typeof value}`);
}
}
const maybeString: unknown = getConfig('name');
assertIsString(maybeString); // throws if not string
// From this point on, maybeString is narrowed to string
console.log(maybeString.toUpperCase()); // โ
no assertion neededThe unknown Pattern for Catch Blocks
TypeScript 4.0+ types catch clause variables as unknown. Proper type guards here prevent runtime errors in error handling code.
try {
await dangerousOperation();
} catch (error) {
// error is unknown โ you must narrow before using
if (error instanceof Error) {
console.error(error.message); // โ
} else if (typeof error === 'string') {
console.error(error); // โ
} else {
console.error('Unknown error:', JSON.stringify(error));
}
}
// Reusable helper
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'An unexpected error occurred';
}Pitfalls
Type assertions vs type guards
// โ Type assertion โ silences the error but doesn't check at runtime
const user = data as User; // will crash if data isn't actually a User
// โ
Type guard โ validates at runtime AND narrows the type
if (isUser(data)) {
const user = data; // User โ safe
}Forgetting the exhaustiveness check
Always include the default: const _never: never = ... pattern in switch statements over discriminated unions. Without it, adding a new union member compiles fine but silently hits the missing case at runtime.

