Skip to main content

Module 8: Type Guards and Narrowing

Type guards and narrowing are essential techniques for safely working with union types and ensuring type safety in TypeScript.


1. What is Type Narrowing?​

Type narrowing is the process of refining a broader type to a more specific type within a conditional block.

function printValue(value: string | number): void {
// value is string | number here

if (typeof value === "string") {
// value is narrowed to string here
console.log(value.toUpperCase());
} else {
// value is narrowed to number here
console.log(value.toFixed(2));
}
}

2. typeof Type Guards​

The typeof operator checks primitive types at runtime.

function processValue(value: string | number | boolean): void {
if (typeof value === "string") {
console.log(`String: ${value.toUpperCase()}`);
} else if (typeof value === "number") {
console.log(`Number: ${value.toFixed(2)}`);
} else {
console.log(`Boolean: ${value ? "true" : "false"}`);
}
}

typeof Results​

typeof "hello"    // "string"
typeof 42 // "number"
typeof true // "boolean"
typeof undefined // "undefined"
typeof {} // "object"
typeof [] // "object"
typeof null // "object" (quirk!)
typeof function // "function"
typeof Limitation

typeof null returns "object" (JavaScript quirk). Use strict equality for null checks.


3. Truthiness Narrowing​

TypeScript narrows types based on truthiness checks.

function printLength(value: string | null | undefined): void {
if (value) {
// value is narrowed to string
console.log(value.length);
} else {
console.log("No value provided");
}
}

Falsy Values in JavaScript​

// These are falsy:
false, 0, -0, 0n, "", null, undefined, NaN

4. Equality Narrowing​

Use === or !== to narrow types.

function compare(x: string | number, y: string | boolean): void {
if (x === y) {
// x and y are both narrowed to string
console.log(x.toUpperCase());
console.log(y.toUpperCase());
}
}

Null/Undefined Checks​

function greet(name: string | null | undefined): void {
if (name !== null && name !== undefined) {
console.log(`Hello, ${name}!`);
}
}

// Shorthand
function greet2(name: string | null | undefined): void {
if (name != null) { // != checks both null and undefined
console.log(`Hello, ${name}!`);
}
}

5. instanceof Type Guard​

Check if an object is an instance of a class.

class Dog {
bark(): void {
console.log("Woof!");
}
}

class Cat {
meow(): void {
console.log("Meow!");
}
}

function makeSound(animal: Dog | Cat): void {
if (animal instanceof Dog) {
animal.bark(); // animal is narrowed to Dog
} else {
animal.meow(); // animal is narrowed to Cat
}
}

let dog = new Dog();
makeSound(dog); // "Woof!"

6. in Operator Narrowing​

Check if a property exists on an object.

interface Bird {
fly(): void;
layEggs(): void;
}

interface Fish {
swim(): void;
layEggs(): void;
}

function move(animal: Bird | Fish): void {
if ("fly" in animal) {
animal.fly(); // animal is narrowed to Bird
} else {
animal.swim(); // animal is narrowed to Fish
}
}

7. Custom Type Guards​

Create custom type guard functions using type predicates.

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

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

// Custom type guard
function isAdmin(user: User | Admin): user is Admin {
return (user as Admin).permissions !== undefined;
}

function printUserInfo(user: User | Admin): void {
if (isAdmin(user)) {
console.log(`Admin: ${user.name}, Permissions: ${user.permissions.join(", ")}`);
} else {
console.log(`User: ${user.name}`);
}
}

Type Predicate Pattern​

function isString(value: unknown): value is string {
return typeof value === "string";
}

function isNumber(value: unknown): value is number {
return typeof value === "number";
}

function processValue(value: unknown): void {
if (isString(value)) {
console.log(value.toUpperCase());
} else if (isNumber(value)) {
console.log(value.toFixed(2));
}
}

8. Discriminated Unions with Type Guards​

interface Circle {
kind: "circle";
radius: number;
}

interface Square {
kind: "square";
sideLength: number;
}

interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}

type Shape = Circle | Square | Rectangle;

function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
case "rectangle":
return shape.width * shape.height;
default:
// Exhaustiveness check
const _exhaustive: never = shape;
return _exhaustive;
}
}
Exhaustiveness Checking

Use never type to ensure all cases are handled in discriminated unions.


9. Assertion Functions​

Throw errors if type assumptions are violated.

function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Value must be a string");
}
}

function processString(value: unknown): void {
assertIsString(value);
// After this point, value is narrowed to string
console.log(value.toUpperCase());
}

Assert Non-Null​

function assertDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error("Value must not be null or undefined");
}
}

function getLength(value: string | null | undefined): number {
assertDefined(value);
// value is narrowed to string here
return value.length;
}

10. Control Flow Analysis​

TypeScript tracks variable types through control flow.

function example(x: string | number | boolean) {
if (typeof x === "string") {
console.log(x.toUpperCase());
return;
}

if (typeof x === "number") {
console.log(x.toFixed(2));
return;
}

// TypeScript knows x must be boolean here
console.log(x ? "true" : "false");
}

11. Type Guards with Arrays​

function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}

function processArray(value: unknown): void {
if (isStringArray(value)) {
value.forEach(str => console.log(str.toUpperCase()));
}
}

12. Real-World Example: Form Validation​

interface ValidationResult {
valid: boolean;
errors?: string[];
}

function isValidationSuccess(result: ValidationResult): result is ValidationResult & { valid: true } {
return result.valid === true;
}

function handleValidation(result: ValidationResult): void {
if (isValidationSuccess(result)) {
console.log("Validation passed!");
// No need to check for errors
} else {
console.log("Validation failed:");
result.errors?.forEach(error => console.log(`- ${error}`));
}
}

13. Narrowing with Discriminated Unions​

type NetworkState =
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: Error };

function handleState(state: NetworkState): void {
switch (state.status) {
case "loading":
console.log("Loading...");
break;
case "success":
console.log("Data:", state.data);
break;
case "error":
console.log("Error:", state.error.message);
break;
}
}

Key Takeaways​

✅ Type narrowing refines types within conditional blocks
✅ Use typeof for primitive type checks
✅ Use instanceof for class instance checks
✅ Use in operator to check property existence
✅ Custom type guards with type predicates
✅ Assertion functions throw on invalid types
✅ Discriminated unions with exhaustiveness checking


Practice Exercises​

Exercise 1: Animal Type Guard​

interface Dog {
type: "dog";
bark(): void;
}

interface Cat {
type: "cat";
meow(): void;
}

type Pet = Dog | Cat;

function isPet(animal: unknown): animal is Pet {
return (
typeof animal === "object" &&
animal !== null &&
"type" in animal &&
(animal.type === "dog" || animal.type === "cat")
);
}

Exercise 2: Result Type​

type Result<T, E> =
| { success: true; value: T }
| { success: false; error: E };

function handleResult<T, E>(result: Result<T, E>): void {
if (result.success) {
console.log("Success:", result.value);
} else {
console.log("Error:", result.error);
}
}

Exercise 3: Type Guard for API Response​

Create type guards to safely handle API responses.


Next Steps​

In Module 9, we'll explore Advanced Types including mapped types, conditional types, and template literal types.