Type narrowing
In this chapter, you learn about "type narrowing," which is at the core of TypeScript's type system.
What you learn in this chapter
- How to use union and intersection types and the difference between them
- Narrowing types with type guards (typeof, in, instanceof)
- Tagged unions (discriminated unions)
- Custom type guard functions
- Type assertions (as) and the non-null assertion (!)
You can check the content of this chapter on the TypeScript Playground too. Learn by writing code.
Union types
What is a union type
A union type represents one of several types. You define it with the | (pipe) symbol.
// The basics of a union type
// A variable that can hold a string or a number
let value: string | number;
value = "hello"; // OK: string
value = 100; // OK: number
// value = true; // Error: boolean is not allowed
Union types and functions
// A function that takes a union type
// id takes a string or a number
function printId(id: string | number): void {
console.log(`Your ID is: ${id}`);
}
printId(101); // OK
printId("ABC123"); // OK
// printId(true); // Error: boolean is not allowed
Restrictions on union types and how to handle them
On a union-type variable, you can only access members common to all the types.
function printLength(value: string | number): void {
// Error: number has no length property
// console.log(value.length);
// Solution: narrow the type with a type guard
if (typeof value === "string") {
// inside this branch, value is treated as a string
console.log(value.length); // OK
} else {
console.log("Not a string");
}
}
Arrays of union types
// The array elements are a string or a number
// Each element may be a string or a number
let mixedArray: (string | number)[];
mixedArray = [1, "two", 3, "four"]; // OK
// "an array of strings" or "an array of numbers"
// The whole array is one type or the other
let arrayUnion: string[] | number[];
arrayUnion = ["a", "b", "c"]; // OK: string[]
arrayUnion = [1, 2, 3]; // OK: number[]
// arrayUnion = [1, "a"]; // Error: a mix is not allowed
Intersection types
What is an intersection type
An intersection type is a type that satisfies several types at once. You define it with the & (ampersand) symbol.
// Define two types
type Person = {
name: string;
age: number;
};
type Employee = {
employeeId: string;
department: string;
};
// Combine with an intersection type
// It has the properties of both Person and Employee
type Staff = Person & Employee;
const staff: Staff = {
name: "Tanaka Taro",
age: 30,
employeeId: "EMP001",
department: "Development department"
};
// Missing even one property is an error
A practical example: combining capabilities
// Define each capability as a separate type
type Readable = {
read(): void;
};
type Writable = {
write(data: string): void;
};
type Closable = {
close(): void;
};
// A type that combines the capabilities
// It must have all the methods
type FileHandle = Readable & Writable & Closable;
// Implement the FileHandle type with a class
class TextFile implements FileHandle {
read(): void {
console.log("Reading the file");
}
write(data: string): void {
console.log(`Writing data: ${data}`);
}
close(): void {
console.log("Closing the file");
}
}
The difference between union and intersection types
// Union type: one of the types (OR)
type StringOrNumber = string | number;
// → a string or a number is OK
// Intersection type: satisfies all the types (AND)
type StringAndNumber = string & number;
// → a string and a number → the never type (does not exist)
// Intersection is useful for object types
type Combined = { a: string } & { b: number };
// → { a: string; b: number } has both properties
Type guards
A type guard is a technique for narrowing a type with conditional branching.
typeof type guards
function processValue(value: string | number): string {
// Check the type with the typeof operator
if (typeof value === "string") {
// inside this, value is treated as a string
return value.toUpperCase();
} else {
// inside this, value is treated as a number
return value.toFixed(2);
}
}
console.log(processValue("hello")); // "HELLO"
console.log(processValue(123.456)); // "123.46"
Types you can determine with the typeof operator:
| Result | Target type |
|---|---|
"string" | string |
"number" | number |
"boolean" | boolean |
"object" | object (including null) |
"function" | function |
"undefined" | undefined |
"symbol" | symbol |
"bigint" | BigInt |
Use Array.isArray to check for an array
typeof cannot identify an array (an array also returns "object"). When you want to check for an array, use Array.isArray(). TypeScript recognizes this as a type guard, so inside if (Array.isArray(x)), x is automatically narrowed to an array type.
function processValue(value: string | string[]): number {
if (Array.isArray(value)) {
return value.length // value is string[]
}
return value.length // value is string
}
The in operator as a type guard
Check whether a property exists on an object.
type Dog = {
name: string;
bark(): void;
};
type Cat = {
name: string;
meow(): void;
};
type Pet = Dog | Cat;
function makeSound(pet: Pet): void {
// The name property exists on both, so you can access it directly
console.log(`${pet.name} makes a sound:`);
// Check for the existence of a property with the in operator
if ("bark" in pet) {
// it has the bark property, so pet is a Dog
pet.bark();
} else {
// it has no bark, so pet is a Cat
pet.meow();
}
}
const dog: Dog = {
name: "Rex",
bark() { console.log("Woof woof!"); }
};
const cat: Cat = {
name: "Whiskers",
meow() { console.log("Meow!"); }
};
makeSound(dog); // "Rex makes a sound: Woof woof!"
makeSound(cat); // "Whiskers makes a sound: Meow!"
instanceof type guards
Determine whether a value is an instance of a class.
class Bird {
fly(): void {
console.log("Flying in the sky");
}
sing(): void {
console.log("Singing");
}
}
class Fish {
swim(): void {
console.log("Swimming");
}
}
type Animal = Bird | Fish;
function move(animal: Animal): void {
// Check whether it is a class instance with the instanceof operator
if (animal instanceof Bird) {
// animal is treated as a Bird
animal.fly();
animal.sing();
} else {
// animal is treated as a Fish
animal.swim();
}
}
const bird = new Bird();
const fish = new Fish();
move(bird); // "Flying in the sky" "Singing"
move(fish); // "Swimming"
Tagged unions (discriminated unions)
Using a literal type as a tag lets you discriminate types safely.
// The status property is a literal-type tag
type Success = {
status: "success"; // the literal type "success"
data: string;
};
type Failure = {
status: "failure"; // the literal type "failure"
error: string;
};
type Result = Success | Failure;
function handleResult(result: Result): void {
// Discriminate the type by the value of the status property
if (result.status === "success") {
// result is treated as Success
console.log(`Success: ${result.data}`);
} else {
// result is treated as Failure
console.log(`Failure: ${result.error}`);
}
}
// You can also use a switch statement
function handleResultSwitch(result: Result): void {
switch (result.status) {
case "success":
console.log(`Success: ${result.data}`);
break;
case "failure":
console.log(`Failure: ${result.error}`);
break;
}
}
const success: Success = { status: "success", data: "Processing complete" };
const failure: Failure = { status: "failure", error: "An error occurred" };
handleResult(success); // "Success: Processing complete"
handleResult(failure); // "Failure: An error occurred"
A practical example: managing API response state
// Loading
type LoadingState = {
status: "loading";
};
// Success
type SuccessState<T> = {
status: "success";
data: T;
};
// Error
type ErrorState = {
status: "error";
error: string;
};
// One of the three states
type ApiState<T> = LoadingState | SuccessState<T> | ErrorState;
// The user information type
type User = {
id: number;
name: string;
};
function renderUI(state: ApiState<User>): void {
switch (state.status) {
case "loading":
console.log("Loading...");
break;
case "success":
// it is the SuccessState type, so you can access data
console.log(`User: ${state.data.name}`);
break;
case "error":
// it is the ErrorState type, so you can access error
console.log(`Error: ${state.error}`);
break;
}
}
Exhaustiveness checks
When you combine a tagged union with a switch statement, you can create a mechanism where a compile error occurs unless you handle every case. This is called an exhaustiveness check, and it uses the never type.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; size: number }
| { kind: 'rectangle'; width: number; height: number }
function area(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
default: {
// when every case is handled, shape becomes the never type
// if a case is missing, there is a type error here → caught at compile time
const _exhaustive: never = shape
throw new Error(`Unhandled shape kind: ${JSON.stringify(_exhaustive)}`)
}
}
}
The value of this pattern is that when you later add a new kind to Shape, a compile error tells you "add a case here too." Since it is hard to notice a missing case when writing by hand, combining an exhaustiveness check is the recommended pattern when you work with tagged unions.
A name starting with an underscore, like _exhaustive, is a naming convention indicating "a variable that exists only for type checking." In this example, _exhaustive is used to build the error message, so it does not cause an unused-variable error even with TypeScript's noUnusedLocals option (tsconfig.json). Your team's conventions may use a different expression (for example, satisfies never).
default:
// you can do an equivalent check with satisfies without defining a variable
shape satisfies never
throw new Error(`Unhandled: ${(shape as { kind: string }).kind}`)
Custom type guard functions
You can turn complex type-checking logic into reusable functions.
// A type guard function using a type predicate
// The return type "value is string" is the key
function isString(value: unknown): value is string {
return typeof value === "string";
}
function isNumber(value: unknown): value is number {
return typeof value === "number";
}
function processUnknown(value: unknown): void {
// Narrow the type with a custom type guard function
if (isString(value)) {
// value is treated as a string
console.log(value.toUpperCase());
} else if (isNumber(value)) {
// value is treated as a number
console.log(value.toFixed(2));
} else {
console.log("Neither a string nor a number");
}
}
processUnknown("hello"); // "HELLO"
processUnknown(123.456); // "123.46"
processUnknown(true); // "Neither a string nor a number"
A type guard function for objects
// The user type definition
interface User {
id: number;
name: string;
email: string;
}
// A type guard function that determines whether a value is a User
function isUser(obj: unknown): obj is User {
// Check whether it is null and whether it is an object
if (typeof obj !== "object" || obj === null) {
return false;
}
// Temporarily treat it as a User with a type assertion
const user = obj as User;
// Check that all required properties exist and have the correct types
return (
typeof user.id === "number" &&
typeof user.name === "string" &&
typeof user.email === "string"
);
}
// Usage example: safely process data fetched from an API
function greetUser(data: unknown): void {
if (isUser(data)) {
// data is treated as a User
console.log(`Hello, ${data.name}!`);
console.log(`Email: ${data.email}`);
} else {
console.log("Invalid user data");
}
}
const validUser = { id: 1, name: "Yamada Taro", email: "yamada@example.com" };
const invalidData = { id: 1, name: "Tanaka" }; // email is missing
greetUser(validUser); // "Hello, Yamada Taro!"
greetUser(invalidData); // "Invalid user data"
Assertion functions (asserts)
Using the asserts keyword, you can define a function that throws an exception when a condition is not met. If the function returns normally, TypeScript narrows the type.
// Guarantee that a value is not null/undefined
function assertIsDefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error("Value must be defined");
}
}
function processUser(user: User | null): void {
// user is User | null
assertIsDefined(user);
// because assertIsDefined did not throw, user is narrowed to the User type
console.log(user.name); // OK: user is a User
}
The difference between asserts and a type predicate
// A type predicate: returns a boolean
function isString(value: unknown): value is string {
return typeof value === "string";
}
// An assertion function: throws an exception, or guarantees the type
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error(`Expected string, got ${typeof value}`);
}
}
function example(value: unknown): void {
// Type predicate: use it in an if statement
if (isString(value)) {
console.log(value.toUpperCase()); // OK
}
// Assertion function: the type is determined after the call
assertIsString(value);
console.log(value.toUpperCase()); // OK (from here, value is a string)
}
A practical example: a validation function
interface User {
id: number;
name: string;
email: string;
}
function assertIsUser(value: unknown): asserts value is User {
if (typeof value !== "object" || value === null) {
throw new Error("Expected object");
}
const obj = value as Record<string, unknown>;
if (typeof obj.id !== "number") {
throw new Error("Expected id to be number");
}
if (typeof obj.name !== "string") {
throw new Error("Expected name to be string");
}
if (typeof obj.email !== "string") {
throw new Error("Expected email to be string");
}
}
// Safely process a response from an API
async function fetchUser(): Promise<User> {
const response = await fetch("/api/user");
const data: unknown = await response.json();
assertIsUser(data); // throws if the data is invalid
return data; // can be returned as a User
}
Type assertions
A feature that explicitly tells the TypeScript compiler "this value is a specific type."
A basic type assertion
// Using the as syntax (recommended)
let someValue: unknown = "this is a string";
let strLength: number = (someValue as string).length;
// The angle-bracket syntax (cannot be used in JSX)
let someValue2: unknown = "this is a string";
let strLength2: number = (<string>someValue2).length;
A type assertion is not a type conversion. It is only effective at compile time and does nothing at runtime. An incorrect type assertion can cause a runtime error, so use it with care.
A type assertion for a DOM element
// Getting a DOM element (the return value is HTMLElement | null)
const inputElement = document.getElementById("username");
// Specify the concrete type with a type assertion
const input = document.getElementById("username") as HTMLInputElement;
// You can access HTMLInputElement properties
input.value = "Initial value";
input.placeholder = "Enter your username";
// A safer approach: use a type guard
const maybeInput = document.getElementById("username");
if (maybeInput instanceof HTMLInputElement) {
maybeInput.value = "Initial value"; // safely access it with a type guard
}
The non-null assertion (!)
Indicate that a value is not null or undefined.
// The non-null assertion operator (!)
function processValue(value: string | null | undefined): void {
// adding ! skips the null check
console.log(value!.toUpperCase());
// Note: if it is actually null, there is a runtime error
}
// A safer approach: use a type guard
function processValueSafe(value: string | null | undefined): void {
if (value !== null && value !== undefined) {
console.log(value.toUpperCase()); // safely with a type guard
}
}
Use the non-null assertion with care. If the value is actually null, there is a runtime error. It is safer to use a type guard whenever possible.
Try it: implement a type guard for an API response ★★
Implement a function that safely processes the following API response type.
Requirements:
- Define a tagged union type of
SuccessResponseandErrorResponse - Implement a function that processes the response
- Return the data on success, and the error message on failure
// Implement using the following types
type SuccessResponse = {
status: "success";
data: {
id: number;
message: string;
};
};
type ErrorResponse = {
status: "error";
error: {
code: number;
message: string;
};
};
type ApiResponse = SuccessResponse | ErrorResponse;
// Implement the handleResponse function
function handleResponse(response: ApiResponse): string {
// implement here
}
Hint
- You can discriminate the type by the value of
response.status - When
status === "success", you can accessresponse.data - When
status === "error", you can accessresponse.error
Answer and explanation
type SuccessResponse = {
status: "success";
data: {
id: number;
message: string;
};
};
type ErrorResponse = {
status: "error";
error: {
code: number;
message: string;
};
};
type ApiResponse = SuccessResponse | ErrorResponse;
// A custom type guard function (optional)
function isSuccessResponse(response: ApiResponse): response is SuccessResponse {
return response.status === "success";
}
function handleResponse(response: ApiResponse): string {
// Discriminate the type with the tagged union
if (response.status === "success") {
// treated as the SuccessResponse type
return `Success (ID: ${response.data.id}): ${response.data.message}`;
} else {
// treated as the ErrorResponse type
return `Error (Code: ${response.error.code}): ${response.error.message}`;
}
}
// Tests
const success: SuccessResponse = {
status: "success",
data: { id: 1, message: "Data retrieved" }
};
const error: ErrorResponse = {
status: "error",
error: { code: 404, message: "Data not found" }
};
console.log(handleResponse(success));
// "Success (ID: 1): Data retrieved"
console.log(handleResponse(error));
// "Error (Code: 404): Data not found"
Explanation:
- Because the
statusproperty is the literal type"success"or"error", it works as a tagged union - After the
response.status === "success"check, TypeScript treats response as theSuccessResponsetype - This lets you access
response.datain a type-safe way
Summary
What you learned in this chapter:
- Union types (
|) express "one of the types" - Intersection types (
&) express a type that "satisfies all the types" - Type guards are techniques for narrowing a type with conditional branching
typeof: determine a primitive typein: check for the existence of a propertyinstanceof: determine a class instance
- Tagged unions are a pattern for discriminating types with a literal type
- Custom type guard functions make complex checks reusable
- Type assertions and non-null assertions must be used with care
In the next chapter, you will learn about more advanced type operations such as the keyof operator and lookup types.
Common pitfalls for beginners
Common mistakes
❌ Not knowing that typeof null returns "object"
function process(value: string | object | null): void {
if (typeof value === "object") {
// value becomes object | null (null is not excluded!)
// console.log(value.toString()); // possible runtime error
}
}
// ✅ The correct way
function processSafe(value: string | object | null): void {
if (value !== null && typeof value === "object") {
console.log(value.toString()); // OK
}
}
Cause: Due to a historical quirk of JavaScript, typeof null returns "object".
Solution: Do the null check first, or add value !== null to the condition.
❌ Thinking a type guard's result is valid outside the branch
function example(value: string | number): void {
if (typeof value === "string") {
console.log(value.toUpperCase()); // OK
}
// here, value is string | number again
// console.log(value.toUpperCase()); // Error
}
Cause: Type narrowing is only valid inside that branch block. Solution: Do all the processing after narrowing inside that branch.
❌ Forgetting the type predicate in a custom type guard function
// ❌ With a boolean return value, the type is not narrowed
function isString(value: unknown): boolean {
return typeof value === "string";
}
function process(value: unknown): void {
if (isString(value)) {
// value is still unknown
// console.log(value.toUpperCase()); // Error
}
}
// ✅ Use a type predicate
function isStringCorrect(value: unknown): value is string {
return typeof value === "string";
}
function processSafe(value: unknown): void {
if (isStringCorrect(value)) {
console.log(value.toUpperCase()); // OK
}
}
Cause: Without the type predicate value is string, TypeScript cannot narrow the type.
Solution: In a custom type guard function, always specify the return type in the form value is Type.
❌ Overusing type assertions (as)
// ❌ Forcing a type conversion with a type assertion
const input = document.getElementById("input");
const value = (input as HTMLInputElement).value; // crashes if input is null
// ✅ Process safely with a type guard
const inputSafe = document.getElementById("input");
if (inputSafe instanceof HTMLInputElement) {
const value = inputSafe.value; // OK
}
Cause: A type assertion is compile-time only and has no runtime check. Solution: Prefer type guards over type assertions, and use assertions only when truly necessary.
❌ Comparing the tag value as a string in a tagged union
type Result =
| { status: "success"; data: string }
| { status: "error"; message: string };
// ❌ A typo is hard to notice
function handle(result: Result): void {
if (result.status === "sucess") { // typo! but it may not be an error
// ...
}
}
// ✅ Using a switch statement makes a typo an error
function handleSafe(result: Result): void {
switch (result.status) {
case "success": // autocompletion works, a typo is an error
console.log(result.data);
break;
case "error":
console.log(result.message);
break;
}
}
Cause: A string comparison in an if statement can make a typo hard to notice. Solution: Use a switch statement for tagged unions; autocompletion works and it prevents typos.