Skip to main content

Chapter 8: Type Guards & Assertions

Authored by syscook.dev

What are Type Guards and Assertions?

Type guards are expressions that perform runtime checks to narrow down the type of a variable within a specific scope. Type assertions tell the TypeScript compiler to treat a value as a specific type, bypassing the compiler's type checking.

Key Concepts:

  • Type Guards: Runtime checks that narrow types
  • Type Assertions: Compile-time type overrides
  • User-Defined Type Guards: Custom type guard functions
  • Built-in Type Guards: typeof, instanceof, in operator
  • Type Predicates: Functions that return type predicates
  • Assertion Signatures: Functions that assert types

Why Use Type Guards and Assertions?

1. Runtime Type Safety

Type guards provide runtime type checking to ensure type safety.

// Without type guards - potential runtime errors
function processValue(value: unknown) {
// This could fail at runtime
return value.toUpperCase(); // Error: Object is of type 'unknown'
}

// With type guards - safe runtime checking
function processValue(value: unknown) {
if (typeof value === 'string') {
return value.toUpperCase(); // Type is narrowed to string
}
throw new Error('Value must be a string');
}

// Usage
try {
const result = processValue("hello");
console.log("Result:", result);
} catch (error) {
console.error("Error:", error.message);
}

Output:

Result: HELLO

2. Type Narrowing

Type guards narrow the type of variables within specific scopes.

// Type narrowing with type guards
function processData(data: string | number | boolean) {
if (typeof data === 'string') {
// data is narrowed to string
console.log("String length:", data.length);
console.log("Uppercase:", data.toUpperCase());
} else if (typeof data === 'number') {
// data is narrowed to number
console.log("Number squared:", data * data);
console.log("Is integer:", Number.isInteger(data));
} else {
// data is narrowed to boolean
console.log("Boolean value:", data);
console.log("Negated:", !data);
}
}

// Usage
processData("Hello World");
processData(42);
processData(true);

Output:

String length: 11
Uppercase: HELLO WORLD
Number squared: 1764
Is integer: true
Boolean value: true
Negated: false

How to Use Type Guards and Assertions?

1. Built-in Type Guards

typeof Operator

// typeof type guard
function handleValue(value: unknown) {
if (typeof value === 'string') {
console.log("String:", value.toUpperCase());
} else if (typeof value === 'number') {
console.log("Number:", value.toFixed(2));
} else if (typeof value === 'boolean') {
console.log("Boolean:", value ? "true" : "false");
} else if (typeof value === 'object') {
if (value === null) {
console.log("Null value");
} else {
console.log("Object:", Object.keys(value));
}
} else if (typeof value === 'function') {
console.log("Function:", value.name);
} else {
console.log("Other type:", typeof value);
}
}

// Usage
handleValue("Hello");
handleValue(42.567);
handleValue(true);
handleValue({ name: "John", age: 30 });
handleValue(null);
handleValue(() => console.log("test"));
handleValue(undefined);

Output:

String: HELLO
Number: 42.57
Boolean: true
Object: ['name', 'age']
Null value
Function: test
Other type: undefined

instanceof Operator

// instanceof type guard
class Animal {
constructor(public name: string) {}
makeSound(): void {
console.log("Some animal sound");
}
}

class Dog extends Animal {
constructor(name: string, public breed: string) {
super(name);
}

makeSound(): void {
console.log("Woof! Woof!");
}

fetch(): void {
console.log(`${this.name} is fetching the ball`);
}
}

class Cat extends Animal {
constructor(name: string, public color: string) {
super(name);
}

makeSound(): void {
console.log("Meow! Meow!");
}

purr(): void {
console.log(`${this.name} is purring`);
}
}

function handleAnimal(animal: Animal) {
console.log(`Handling animal: ${animal.name}`);
animal.makeSound();

if (animal instanceof Dog) {
// animal is narrowed to Dog
console.log(`Breed: ${animal.breed}`);
animal.fetch();
} else if (animal instanceof Cat) {
// animal is narrowed to Cat
console.log(`Color: ${animal.color}`);
animal.purr();
}
}

// Usage
const dog = new Dog("Buddy", "Golden Retriever");
const cat = new Cat("Whiskers", "Orange");

handleAnimal(dog);
console.log("---");
handleAnimal(cat);

Output:

Handling animal: Buddy
Woof! Woof!
Breed: Golden Retriever
Buddy is fetching the ball
---
Handling animal: Whiskers
Meow! Meow!
Color: Orange
Whiskers is purring

in Operator

// in operator type guard
interface Bird {
fly(): void;
feathers: boolean;
}

interface Fish {
swim(): void;
fins: boolean;
}

function handleCreature(creature: Bird | Fish) {
if ('fly' in creature) {
// creature is narrowed to Bird
console.log("It's a bird!");
creature.fly();
console.log("Has feathers:", creature.feathers);
} else if ('swim' in creature) {
// creature is narrowed to Fish
console.log("It's a fish!");
creature.swim();
console.log("Has fins:", creature.fins);
}
}

// Usage
const bird: Bird = {
fly: () => console.log("Flying high!"),
feathers: true
};

const fish: Fish = {
swim: () => console.log("Swimming deep!"),
fins: true
};

handleCreature(bird);
console.log("---");
handleCreature(fish);

Output:

It's a bird!
Flying high!
Has feathers: true
---
It's a fish!
Swimming deep!
Has fins: true

2. User-Defined Type Guards

Type Predicate Functions

// User-defined type guard with type predicate
interface User {
id: number;
name: string;
email: string;
}

interface Admin {
id: number;
name: string;
email: string;
permissions: string[];
}

type UserOrAdmin = User | Admin;

// Type guard function
function isAdmin(user: UserOrAdmin): user is Admin {
return 'permissions' in user;
}

function isUser(user: UserOrAdmin): user is User {
return !('permissions' in user);
}

function handleUser(user: UserOrAdmin) {
if (isAdmin(user)) {
// user is narrowed to Admin
console.log(`Admin: ${user.name}`);
console.log("Permissions:", user.permissions);
} else if (isUser(user)) {
// user is narrowed to User
console.log(`User: ${user.name}`);
console.log("Email:", user.email);
}
}

// Usage
const regularUser: User = {
id: 1,
name: "John",
email: "[email protected]"
};

const adminUser: Admin = {
id: 2,
name: "Alice",
email: "[email protected]",
permissions: ["read", "write", "delete"]
};

handleUser(regularUser);
console.log("---");
handleUser(adminUser);

Output:

User: John
Email: [email protected]
---
Admin: Alice
Permissions: ['read', 'write', 'delete']

Complex Type Guards

// Complex type guard for API responses
interface SuccessResponse<T> {
success: true;
data: T;
timestamp: string;
}

interface ErrorResponse {
success: false;
error: string;
code: number;
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse;

// Type guard functions
function isSuccessResponse<T>(response: ApiResponse<T>): response is SuccessResponse<T> {
return response.success === true;
}

function isErrorResponse<T>(response: ApiResponse<T>): response is ErrorResponse {
return response.success === false;
}

// Generic type guard
function isValidResponse<T>(response: any): response is ApiResponse<T> {
return typeof response === 'object' &&
response !== null &&
typeof response.success === 'boolean' &&
(response.success ? 'data' in response : 'error' in response);
}

function handleApiResponse<T>(response: ApiResponse<T>) {
if (isSuccessResponse(response)) {
// response is narrowed to SuccessResponse<T>
console.log("Success!");
console.log("Data:", response.data);
console.log("Timestamp:", response.timestamp);
} else if (isErrorResponse(response)) {
// response is narrowed to ErrorResponse
console.log("Error!");
console.log("Error message:", response.error);
console.log("Error code:", response.code);
}
}

// Usage
const successResponse: ApiResponse<string> = {
success: true,
data: "Hello World",
timestamp: new Date().toISOString()
};

const errorResponse: ApiResponse<string> = {
success: false,
error: "Not Found",
code: 404
};

handleApiResponse(successResponse);
console.log("---");
handleApiResponse(errorResponse);

Output:

Success!
Data: Hello World
Timestamp: 2024-01-15T10:30:00.000Z
---
Error!
Error message: Not Found
Error code: 404

3. Type Assertions

Basic Type Assertions

// Type assertions
function getValue(): unknown {
return "Hello World";
}

// Type assertion with 'as'
const value = getValue() as string;
console.log("Value length:", value.length);
console.log("Uppercase:", value.toUpperCase());

// Type assertion with angle brackets
const value2 = <string>getValue();
console.log("Value2 length:", value2.length);

// Type assertion with non-null assertion
function getElement(): HTMLElement | null {
return document.getElementById('myElement');
}

const element = getElement()!; // Non-null assertion
console.log("Element:", element);

// Type assertion with any
const anyValue: any = "This is a string";
const stringValue = anyValue as string;
console.log("String value:", stringValue);

Output:

Value length: 11
Uppercase: HELLO WORLD
Value2 length: 11
Element: null
String value: This is a string

Type Assertions with Interfaces

// Type assertions with interfaces
interface User {
id: number;
name: string;
email: string;
}

interface Admin extends User {
permissions: string[];
}

// Type assertion to more specific type
function getUser(): User {
return {
id: 1,
name: "John",
email: "[email protected]"
};
}

const user = getUser();
const admin = user as Admin; // Type assertion
console.log("Admin:", admin);

// Type assertion with unknown
function getUnknownData(): unknown {
return {
id: 2,
name: "Alice",
email: "[email protected]",
permissions: ["read", "write"]
};
}

const unknownData = getUnknownData();
const typedData = unknownData as Admin;
console.log("Typed data:", typedData);

Output:

Admin: { id: 1, name: 'John', email: '[email protected]' }
Typed data: { id: 2, name: 'Alice', email: '[email protected]', permissions: ['read', 'write'] }

4. Assertion Signatures

// Assertion signatures
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Value is not a string');
}
}

function assertIsNumber(value: unknown): asserts value is number {
if (typeof value !== 'number') {
throw new Error('Value is not a number');
}
}

function assertIsArray<T>(value: unknown): asserts value is T[] {
if (!Array.isArray(value)) {
throw new Error('Value is not an array');
}
}

// Usage
function processData(data: unknown) {
assertIsString(data);
// data is now narrowed to string
console.log("String length:", data.length);
console.log("Uppercase:", data.toUpperCase());
}

function processNumbers(numbers: unknown) {
assertIsArray<number>(numbers);
// numbers is now narrowed to number[]
const sum = numbers.reduce((acc, num) => acc + num, 0);
console.log("Sum:", sum);
}

// Usage
try {
processData("Hello World");
processNumbers([1, 2, 3, 4, 5]);
} catch (error) {
console.error("Error:", error.message);
}

Output:

String length: 11
Uppercase: HELLO WORLD
Sum: 15

Practical Examples

1. API Response Handler

// API response handler with type guards
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}

interface User {
id: number;
name: string;
email: string;
}

interface Product {
id: number;
title: string;
price: number;
}

// Type guard for API response
function isApiResponse<T>(response: any): response is ApiResponse<T> {
return typeof response === 'object' &&
response !== null &&
typeof response.data !== 'undefined' &&
typeof response.status === 'number' &&
typeof response.message === 'string';
}

// Type guard for User
function isUser(data: any): data is User {
return typeof data === 'object' &&
data !== null &&
typeof data.id === 'number' &&
typeof data.name === 'string' &&
typeof data.email === 'string';
}

// Type guard for Product
function isProduct(data: any): data is Product {
return typeof data === 'object' &&
data !== null &&
typeof data.id === 'number' &&
typeof data.title === 'string' &&
typeof data.price === 'number';
}

// API handler
function handleApiResponse(response: unknown) {
if (isApiResponse<User>(response)) {
console.log("API Response received");
console.log("Status:", response.status);
console.log("Message:", response.message);

if (isUser(response.data)) {
console.log("User data:", response.data);
console.log("User name:", response.data.name);
console.log("User email:", response.data.email);
}
} else {
console.log("Invalid API response format");
}
}

// Usage
const userResponse: ApiResponse<User> = {
data: {
id: 1,
name: "John",
email: "[email protected]"
},
status: 200,
message: "Success"
};

handleApiResponse(userResponse);

Output:

API Response received
Status: 200
Message: Success
User data: { id: 1, name: 'John', email: '[email protected]' }
User name: John
User email: [email protected]

2. Form Validation System

// Form validation with type guards
interface FormField {
value: unknown;
error?: string;
touched: boolean;
}

interface FormData {
name: FormField;
email: FormField;
age: FormField;
}

// Type guards for form validation
function isString(value: unknown): value is string {
return typeof value === 'string';
}

function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value);
}

function isEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}

function isNotEmpty(value: string): boolean {
return value.trim().length > 0;
}

function isPositiveNumber(value: number): boolean {
return value > 0;
}

// Form validator
class FormValidator {
validateField(field: FormField, fieldName: string): FormField {
const { value, touched } = field;

if (!touched) {
return field;
}

let error: string | undefined;

switch (fieldName) {
case 'name':
if (!isString(value) || !isNotEmpty(value)) {
error = 'Name is required and must be a string';
}
break;

case 'email':
if (!isString(value) || !isNotEmpty(value)) {
error = 'Email is required and must be a string';
} else if (!isEmail(value)) {
error = 'Email must be a valid email address';
}
break;

case 'age':
if (!isNumber(value)) {
error = 'Age must be a number';
} else if (!isPositiveNumber(value)) {
error = 'Age must be a positive number';
}
break;
}

return { ...field, error };
}

validateForm(formData: FormData): FormData {
return {
name: this.validateField(formData.name, 'name'),
email: this.validateField(formData.email, 'email'),
age: this.validateField(formData.age, 'age')
};
}
}

// Usage
const validator = new FormValidator();

const formData: FormData = {
name: { value: "John Doe", touched: true },
email: { value: "[email protected]", touched: true },
age: { value: 25, touched: true }
};

const validatedForm = validator.validateForm(formData);

console.log("Validated form:");
console.log("Name:", validatedForm.name);
console.log("Email:", validatedForm.email);
console.log("Age:", validatedForm.age);

Output:

Validated form:
Name: { value: 'John Doe', touched: true }
Email: { value: '[email protected]', touched: true }
Age: { value: 25, touched: true }

Best Practices

1. Use Type Guards for Runtime Safety

// Good: Use type guards for runtime safety
function processData(data: unknown) {
if (typeof data === 'string') {
// Safe to use string methods
return data.toUpperCase();
}
throw new Error('Expected string');
}

2. Create Reusable Type Guard Functions

// Good: Create reusable type guard functions
function isString(value: unknown): value is string {
return typeof value === 'string';
}

function isNumber(value: unknown): value is number {
return typeof value === 'number' && !isNaN(value);
}

function isArray<T>(value: unknown): value is T[] {
return Array.isArray(value);
}

3. Use Type Assertions Sparingly

// Good: Use type assertions when you're certain about the type
const element = document.getElementById('myElement') as HTMLElement;

// Avoid: Overusing type assertions
const value = someValue as any; // Too permissive

4. Combine Type Guards with Error Handling

// Good: Combine type guards with error handling
function safeProcessData(data: unknown) {
if (typeof data === 'string') {
return data.toUpperCase();
}
throw new Error(`Expected string, got ${typeof data}`);
}

Common Pitfalls and Solutions

1. Type Guard Side Effects

// ❌ Problem: Type guard with side effects
function isString(value: unknown): value is string {
console.log('Checking if string'); // Side effect
return typeof value === 'string';
}

// ✅ Solution: Keep type guards pure
function isString(value: unknown): value is string {
return typeof value === 'string';
}

2. Incorrect Type Assertions

// ❌ Problem: Incorrect type assertion
const value = 42 as string; // Runtime error

// ✅ Solution: Use proper type checking
function processValue(value: unknown) {
if (typeof value === 'string') {
return value.toUpperCase();
}
throw new Error('Expected string');
}

3. Complex Type Guard Logic

// ❌ Problem: Too complex type guard
function isComplexType(value: unknown): value is ComplexType {
return typeof value === 'object' &&
value !== null &&
'prop1' in value &&
'prop2' in value &&
// ... many more checks
}

// ✅ Solution: Break down into simpler guards
function isObject(value: unknown): value is object {
return typeof value === 'object' && value !== null;
}

function hasProperty<T extends object, K extends PropertyKey>(
obj: T,
prop: K
): obj is T & Record<K, unknown> {
return prop in obj;
}

Conclusion

Type guards and assertions in TypeScript provide essential tools for runtime type safety and type narrowing. By understanding:

  • What type guards and assertions are and their purposes
  • Why they're important for runtime safety and type narrowing
  • How to use built-in and create custom type guards

You can create robust applications that handle dynamic data safely. Type guards enable runtime type checking while maintaining compile-time type safety, and type assertions provide controlled ways to override the compiler's type checking when necessary.

Next Steps

  • Practice creating custom type guard functions
  • Explore advanced type guard patterns
  • Learn about assertion signatures
  • Move on to Chapter 9: Async Programming

This tutorial is part of the TypeScript Mastery series by syscook.dev