Day 3: Union Types and Type Narrowing
What You'll Learn Today
- Combining multiple types with Union types (|)
- Using literal types to allow only specific values
- How type narrowing works
- Naming types with type aliases
What Are Union Types?
In real programs, variables often can hold multiple types.
// API response: string on success, null on failure
let response = Math.random() > 0.5 ? "Success!" : null;
// Type of response is string | null
Types like string | null that represent "A or B" are called Union types.
flowchart LR
subgraph Union["Union Type: string | null"]
A["string"]
B["null"]
end
Input["response"] --> Union
Union --> |"\"Success!\""| A
Union --> |"null"| B
style Union fill:#3b82f6,color:#fff
Basic Union Type Syntax
// Union of two types
let id: string | number;
id = "abc123"; // OK
id = 12345; // OK
id = true; // Error: boolean is not assignable to string | number
// Three or more types are possible
let value: string | number | boolean;
value = "hello"; // OK
value = 42; // OK
value = true; // OK
Property Access on Union Types
For Union type values, you can only access properties common to all constituent types.
let value: string | number;
// Methods common to both work
value.toString(); // OK: exists on both string and number
// Methods on only one type don't work
value.toUpperCase(); // Error: toUpperCase does not exist on number
value.toFixed(); // Error: toFixed does not exist on string
Literal Types
Regular types (string, number) allow all values of that type. However, literal types allow only specific values.
// Regular string type: allows all strings
let direction: string = "north";
direction = "south"; // OK
direction = "hello"; // OK (unintended values are also allowed)
// Literal type: allows only specific values
let direction2: "north" | "south" | "east" | "west";
direction2 = "north"; // OK
direction2 = "south"; // OK
direction2 = "hello"; // Error: "hello" is not allowed
flowchart TB
subgraph Literal["Literal Types"]
direction TB
L1["\"north\""]
L2["\"south\""]
L3["\"east\""]
L4["\"west\""]
end
subgraph String["string Type"]
S1["All strings"]
end
Literal --> |"β"| String
style Literal fill:#22c55e,color:#fff
style String fill:#3b82f6,color:#fff
Types of Literal Types
// String literal type
type Status = "pending" | "approved" | "rejected";
// Numeric literal type
type DiceValue = 1 | 2 | 3 | 4 | 5 | 6;
// Boolean literal type (only true or false)
type True = true;
type False = false;
Type Inference Difference Between const and let
// let is mutable, so a wider type is inferred
let message = "Hello"; // Type: string
// const is immutable, so a literal type is inferred
const greeting = "Hello"; // Type: "Hello"
Type Narrowing
When using Union type values, TypeScript can narrow the type through conditional branches. This is called Narrowing.
Narrowing with typeof
function printValue(value: string | number) {
// Here value is string | number
if (typeof value === "string") {
// Here value is string
console.log(value.toUpperCase());
} else {
// Here value is number
console.log(value.toFixed(2));
}
}
flowchart TD
A["value: string | number"] --> B{"typeof value === 'string'?"}
B -->|true| C["value: string"]
B -->|false| D["value: number"]
C --> E["toUpperCase() available"]
D --> F["toFixed() available"]
style A fill:#8b5cf6,color:#fff
style C fill:#22c55e,color:#fff
style D fill:#3b82f6,color:#fff
Narrowing with Truthiness
function printName(name: string | null) {
if (name) {
// If name is truthy, it's string
console.log(name.toUpperCase());
} else {
// If name is falsy, it's null (or empty string)
console.log("No name provided");
}
}
Narrowing with the in Operator
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
// animal is Fish
animal.swim();
} else {
// animal is Bird
animal.fly();
}
}
Narrowing with instanceof
function formatDate(date: Date | string) {
if (date instanceof Date) {
// date is Date
return date.toISOString();
} else {
// date is string
return new Date(date).toISOString();
}
}
Narrowing with Equality
function printLength(value: string | string[] | null) {
if (value === null) {
// value is null
console.log("No value");
} else if (typeof value === "string") {
// value is string
console.log(`String length: ${value.length}`);
} else {
// value is string[]
console.log(`Array length: ${value.length}`);
}
}
Type Aliases
Complex types or frequently used types can be given names with type aliases.
// Define type aliases
type ID = string | number;
type Status = "pending" | "approved" | "rejected";
// Usage
let userId: ID = "user_123";
let postId: ID = 456;
let orderStatus: Status = "pending";
Type Alias Naming Conventions
// Use PascalCase
type UserID = string;
type HttpStatus = 200 | 404 | 500;
type ApiResponse = { data: unknown; error: string | null };
Combining Type Aliases
type StringOrNumber = string | number;
type NullableString = string | null;
// Combine aliases
type ID = StringOrNumber | null;
// Result type based on conditions
type Result =
| { success: true; data: string }
| { success: false; error: string };
Discriminated Unions
In real applications, Union types of objects are commonly used. Having a common property to discriminate between types is convenient.
type SuccessResponse = {
status: "success";
data: string;
};
type ErrorResponse = {
status: "error";
message: string;
};
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse) {
// Discriminate by status property
if (response.status === "success") {
// response is SuccessResponse
console.log(response.data);
} else {
// response is ErrorResponse
console.log(response.message);
}
}
flowchart TD
A["ApiResponse"] --> B{"response.status?"}
B -->|"'success'"| C["SuccessResponse"]
B -->|"'error'"| D["ErrorResponse"]
C --> E["has data property"]
D --> F["has message property"]
style A fill:#8b5cf6,color:#fff
style C fill:#22c55e,color:#fff
style D fill:#ef4444,color:#fff
Discriminated Union Best Practices
// Have a common discriminant property
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; error: Error };
type State = LoadingState | SuccessState | ErrorState;
function renderState(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.join(", ");
case "error":
return `Error: ${state.error.message}`;
}
}
Summary
| Concept | Description | Example |
|---|---|---|
| Union Type | One of multiple types | string | number |
| Literal Type | Only specific values | "north" | "south" |
| Narrowing | Narrow type through conditionals | typeof, in, instanceof |
| Type Alias | Give a name to a type | type ID = string | number |
| Discriminated Union | Discriminate by common property | { status: "success" } | { status: "error" } |
Key Takeaways
- Union types mean "or" - Allow any of multiple types
- Be strict with literal types - Prevent bugs by allowing only specific values
- Leverage narrowing - Narrow types through conditionals for safe operations
- Improve readability with type aliases - Name complex types
Practice Exercises
Exercise 1: Basic
What are the types of the following variables?
let a = Math.random() > 0.5 ? "hello" : 42;
let b = Math.random() > 0.5 ? true : null;
const c = "fixed";
Exercise 2: Narrowing
Complete the following function. Return the length if value is a string, the number itself if it's a number, and 0 otherwise.
function getLength(value: string | number | boolean): number {
// Implement here
}
Challenge
Create a discriminated union type representing a shopping cart state.
empty: Cart is emptyhasItems: Has items (has an items array)checkout: Processing payment (has items array and totalPrice)
Then implement a function getCartMessage that returns a message based on the state.
References
Next Up: In Day 4, we'll learn about "Typing Functions." We'll understand parameter and return type definitions, optional parameters, and function overloads.