Type Guards & Narrowing#
Type guards allow you to narrow down types within conditional blocks, making your code safer and more precise.
typeof Type Guards#
The most basic type guard uses JavaScript's typeof operator:
function processValue(value: string | number): string {
if (typeof value === "string") {
// TypeScript knows value is string here
return value.toUpperCase();
} else {
// TypeScript knows value is number here
return value.toFixed(2);
}
}
// Multiple type checks
function formatValue(value: string | number | boolean): string {
if (typeof value === "string") {
return `"${value}"`;
} else if (typeof value === "number") {
return value.toString();
} else {
return value ? "true" : "false";
}
}
instanceof Type Guards#
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(); // TypeScript knows it's a Dog
} else {
animal.meow(); // TypeScript knows it's a Cat
}
}
// With Error handling
function handleError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
in Operator Narrowing#
Check if a property exists in 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(); // TypeScript knows it's a Bird
} else {
animal.swim(); // TypeScript knows it's a Fish
}
}
// Multiple checks
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; size: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Shape = Circle | Square | Rectangle;
function getArea(shape: Shape): number {
if ("radius" in shape) {
return Math.PI * shape.radius ** 2;
} else if ("size" in shape) {
return shape.size ** 2;
} else {
return shape.width * shape.height;
}
}
Equality Narrowing#
Use strict equality to narrow types:
function processInput(input: string | null | undefined): string {
if (input === null) {
return "Input is null";
}
if (input === undefined) {
return "Input is undefined";
}
// TypeScript knows input is string here
return input.toUpperCase();
}
// Narrowing with specific values
type Status = "pending" | "approved" | "rejected";
function getStatusMessage(status: Status): string {
if (status === "pending") {
return "Waiting for approval";
} else if (status === "approved") {
return "Request approved";
} else {
return "Request rejected";
}
}
Truthiness Narrowing#
TypeScript narrows types based on truthiness checks:
function printLength(str: string | null | undefined): void {
if (str) {
// str is string here (not null or undefined)
console.log(str.length);
} else {
console.log("No string provided");
}
}
// Be careful with falsy values!
function processNumber(num: number | null): void {
if (num) {
console.log(num * 2);
} else {
// This includes 0, which might not be intended!
console.log("No number");
}
}
// Better: explicit null check
function processNumberSafe(num: number | null): void {
if (num !== null) {
console.log(num * 2); // Works with 0 too
} else {
console.log("No number");
}
}
Type Predicates (Custom Type Guards)#
Create your own type guard functions:
// Basic type predicate
function isString(value: unknown): value is string {
return typeof value === "string";
}
function processValue(value: unknown): void {
if (isString(value)) {
console.log(value.toUpperCase()); // TypeScript knows it's a string
}
}
// Object type guards
interface User {
id: number;
name: string;
email: string;
}
function isUser(obj: any): obj is User {
return (
obj &&
typeof obj === "object" &&
typeof obj.id === "number" &&
typeof obj.name === "string" &&
typeof obj.email === "string"
);
}
function greetUser(input: unknown): string {
if (isUser(input)) {
return `Hello, ${input.name}!`; // TypeScript knows it's a User
}
return "Hello, stranger!";
}
// Array type guard
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
function processArray(arr: unknown): void {
if (isStringArray(arr)) {
arr.forEach(str => console.log(str.toUpperCase()));
}
}
Discriminated Unions#
Use a common property to discriminate between types:
// Tagged union pattern
type Success = {
type: "success";
data: any;
};
type Error = {
type: "error";
message: string;
};
type Result = Success | Error;
function handleResult(result: Result): void {
if (result.type === "success") {
console.log("Data:", result.data); // TypeScript knows it's Success
} else {
console.error("Error:", result.message); // TypeScript knows it's Error
}
}
// More complex discriminated union
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; size: number }
| { kind: "rectangle"; width: number; height: number };
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "rectangle":
return shape.width * shape.height;
}
}
// Exhaustiveness checking
type Action =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "RESET"; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
case "RESET":
return action.value;
default:
// This will error if we add a new action type and forget to handle it
const exhaustiveCheck: never = action;
return exhaustiveCheck;
}
}
Control Flow Analysis#
TypeScript tracks types through control flow:
function processUser(user: User | null): void {
if (!user) {
return; // Early return
}
// TypeScript knows user is not null here
console.log(user.name);
console.log(user.email);
}
// With multiple conditions
function validateUser(user: User | null | undefined): boolean {
if (user === null) {
console.log("User is null");
return false;
}
if (user === undefined) {
console.log("User is undefined");
return false;
}
// user is User here
return user.email.includes("@");
}
// Assignment narrowing
function getLength(value: string | null): number {
let result: string;
if (value === null) {
result = "default";
} else {
result = value; // value is narrowed to string
}
return result.length; // result is always string
}
Assertion Functions#
Functions that throw when a condition isn't met:
function assert(condition: any, message?: string): asserts condition {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
function processValue(value: string | null): void {
assert(value !== null, "Value must not be null");
// TypeScript knows value is string here
console.log(value.toUpperCase());
}
// Type assertion
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Value must be a string");
}
}
function processInput(input: unknown): void {
assertIsString(input);
// TypeScript knows input is string here
console.log(input.toUpperCase());
}
// Object assertion
function assertIsUser(obj: any): asserts obj is User {
if (!obj || typeof obj !== "object") {
throw new Error("Not an object");
}
if (typeof obj.id !== "number") {
throw new Error("Missing id");
}
if (typeof obj.name !== "string") {
throw new Error("Missing name");
}
}
Nullish Coalescing and Optional Chaining#
Modern JavaScript features that work well with type narrowing:
interface User {
id: number;
name: string;
address?: {
street: string;
city: string;
};
}
// Optional chaining
function getCity(user: User | null): string | undefined {
return user?.address?.city;
}
// Nullish coalescing
function getDisplayName(user: User | null): string {
return user?.name ?? "Guest";
}
// Combined
function formatAddress(user: User | null): string {
const city = user?.address?.city ?? "Unknown City";
const street = user?.address?.street ?? "Unknown Street";
return `${street}, ${city}`;
}
// Non-null assertion (use sparingly!)
function getUserName(user: User | null): string {
// Only use ! when you're absolutely certain
return user!.name; // Asserts user is not null
}
Practical Examples#
API Response Handler#
type ApiSuccess<T> = {
success: true;
data: T;
};
type ApiError = {
success: false;
error: string;
code: number;
};
type ApiResponse<T> = ApiSuccess<T> | ApiError;
function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccess<T> {
return response.success === true;
}
async function fetchUser(id: string): Promise<User | null> {
const response: ApiResponse<User> = await fetch(`/api/users/${id}`)
.then(r => r.json());
if (isApiSuccess(response)) {
return response.data; // Type: User
} else {
console.error(`Error ${response.code}: ${response.error}`);
return null;
}
}
Form Validation#
interface FormField<T> {
value: T;
error: string | null;
}
function isValidEmail(email: string): boolean {
return email.includes("@") && email.includes(".");
}
function validateField<T>(
field: FormField<T>,
validator: (value: T) => boolean,
errorMessage: string
): FormField<T> {
if (validator(field.value)) {
return { ...field, error: null };
}
return { ...field, error: errorMessage };
}
// Usage
const emailField: FormField<string> = {
value: "user@example.com",
error: null
};
const validatedEmail = validateField(
emailField,
isValidEmail,
"Invalid email address"
);
if (validatedEmail.error === null) {
// Email is valid
console.log(`Valid email: ${validatedEmail.value}`);
}
Event Handler Type Guard#
function isMouseEvent(event: Event): event is MouseEvent {
return event instanceof MouseEvent;
}
function isKeyboardEvent(event: Event): event is KeyboardEvent {
return event instanceof KeyboardEvent;
}
function handleEvent(event: Event): void {
if (isMouseEvent(event)) {
console.log(`Mouse: ${event.clientX}, ${event.clientY}`);
} else if (isKeyboardEvent(event)) {
console.log(`Key pressed: ${event.key}`);
} else {
console.log("Other event");
}
}
document.addEventListener("click", handleEvent);
document.addEventListener("keydown", handleEvent);
Safe JSON Parsing#
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasProperty<K extends string>(
obj: unknown,
key: K
): obj is Record<K, unknown> {
return isObject(obj) && key in obj;
}
function parseUser(json: string): User | null {
try {
const data: unknown = JSON.parse(json);
if (!isObject(data)) {
return null;
}
if (!hasProperty(data, "id") || typeof data.id !== "number") {
return null;
}
if (!hasProperty(data, "name") || typeof data.name !== "string") {
return null;
}
if (!hasProperty(data, "email") || typeof data.email !== "string") {
return null;
}
return data as User;
} catch {
return null;
}
}
State Machine#
type IdleState = { status: "idle" };
type LoadingState = { status: "loading" };
type SuccessState<T> = { status: "success"; data: T };
type ErrorState = { status: "error"; error: string };
type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;
function isSuccessState<T>(state: AsyncState<T>): state is SuccessState<T> {
return state.status === "success";
}
function isErrorState<T>(state: AsyncState<T>): state is ErrorState {
return state.status === "error";
}
function renderState<T>(state: AsyncState<T>): string {
switch (state.status) {
case "idle":
return "Ready";
case "loading":
return "Loading...";
case "success":
return `Data: ${JSON.stringify(state.data)}`;
case "error":
return `Error: ${state.error}`;
}
}
Best Practices#
-
✅ Use type predicates for reusable type guards
function isString(value: unknown): value is string { return typeof value === "string"; } -
✅ Prefer discriminated unions for complex types
type Result = | { success: true; data: any } | { success: false; error: string }; -
✅ Use exhaustiveness checking in switches
default: const exhaustive: never = action; throw new Error(`Unhandled action: ${exhaustive}`); -
✅ Validate unknown data at runtime
function parseData(json: string): Data | null { const data: unknown = JSON.parse(json); if (isValidData(data)) { return data; } return null; } -
❌ Avoid excessive type assertions
// ❌ Unsafe const user = data as User; // ✅ Better - validate first if (isUser(data)) { const user = data; }
Next Steps#
- Learn about Modules & Namespaces for code organization
- Explore Utility Types for advanced type transformations
- Review Generics for reusable type-safe code