Type guards are TypeScript expressions that perform runtime checks to guarantee a type in a specific scope. They allow you to narrow down the type of a variable from a broader type to a more specific one.
function processValue(value: string | number) { if (typeof value === 'string') { // TypeScript knows value is string here return value.toUpperCase(); } else { // TypeScript knows value is number here return value.toFixed(2); }}
The typeof operator works for: 'string', 'number', 'boolean', 'symbol', 'undefined', 'object', and 'function'.
From Exercise 3, the in operator checks if a property exists in an object:
interface User { name: string; age: number; occupation: string;}interface Admin { name: string; age: number; role: string;}type Person = User | Admin;function logPerson(person: Person) { let additionalInformation: string; if ('role' in person) { // TypeScript knows person is Admin here additionalInformation = person.role; } else { // TypeScript knows person is User here additionalInformation = person.occupation; } console.log(` - ${person.name}, ${person.age}, ${additionalInformation}`);}
The in operator is perfect for discriminating between types that have different property names. Itβs safer than checking if a property is truthy because it works even if the property value is undefined or false.
From Exercise 4, hereβs how to create a type predicate:
interface User { type: 'user'; name: string; age: number; occupation: string;}interface Admin { type: 'admin'; name: string; age: number; role: string;}type Person = User | Admin;// Type predicate functionfunction isAdmin(person: Person): person is Admin { return person.type === 'admin';}function isUser(person: Person): person is User { return person.type === 'user';}// Usagefunction logPerson(person: Person) { let additionalInformation = ''; if (isAdmin(person)) { // TypeScript knows person is Admin additionalInformation = person.role; } if (isUser(person)) { // TypeScript knows person is User additionalInformation = person.occupation; } console.log(` - ${person.name}, ${person.age}, ${additionalInformation}`);}
With Type Predicate
Without Type Predicate
function isAdmin(person: Person): person is Admin { return person.type === 'admin';}if (isAdmin(person)) { // person is narrowed to Admin console.log(person.role); // β Works!}
function isAdmin(person: Person): boolean { return person.type === 'admin';}if (isAdmin(person)) { // person is still Person console.log(person.role); // β Error: Property 'role' does not exist}
Type predicates must be implemented correctly! If the runtime check doesnβt match the type assertion, youβll have type safety issues:
// BAD: Incorrect type predicatefunction isAdmin(person: Person): person is Admin { return true; // Always returns true, but claims to check if Admin}// This will cause runtime errors!
function assertNever(value: never): never { throw new Error(`Unexpected value: ${value}`);}function getPerson Info(person: Person): string { switch (person.type) { case 'user': return person.occupation; case 'admin': return person.role; default: // If we add a new Person type and forget to handle it, // TypeScript will error here return assertNever(person); }}
Why use exhaustive checking?
Exhaustive checking ensures that when you add new variants to a discriminated union, TypeScript will remind you to handle them everywhere. This prevents bugs when your types evolve.
// If we add PowerUser:type Person = User | Admin | PowerUser;// TypeScript will error in getPerson Info because we didn't handle PowerUser
TypeScript narrows types based on truthiness checks:
function printLength(value: string | null | undefined) { if (value) { // TypeScript knows value is string here console.log(value.length); } else { // TypeScript knows value is null or undefined console.log('No value'); }}
function compare(x: string | number, y: string | boolean) { if (x === y) { // TypeScript knows both must be string x.toUpperCase(); y.toUpperCase(); }}
TypeScript tracks type narrowing through control flow:
function processValue(value: string | number | null) { if (value === null) { return; } // TypeScript knows value is string | number here if (typeof value === 'string') { console.log(value.toUpperCase()); return; } // TypeScript knows value must be number here console.log(value.toFixed(2));}
Prefer discriminated unions over type predicates when possible
Discriminated unions are more maintainable and less error-prone:
// Good - Discriminated uniontype Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; size: number };function getArea(shape: Shape) { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'square': return shape.size ** 2; }}
Use type predicates for complex runtime checks
When the check is more complex than a simple property comparison:
function isValidEmail(value: string): value is `${string}@${string}.${string}` { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);}
Always implement type predicates correctly
The runtime check must match the type assertion:
// Goodfunction isAdmin(person: Person): person is Admin { return person.type === 'admin';}// Bad - Implementation doesn't match assertionfunction isAdmin(person: Person): person is Admin { return person.name.startsWith('Admin'); // Wrong!}