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 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;
}
}
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.