Table of Contents
- 1. Union Types
- 2. Intersection Types
- 3. Conditional Types
- 4. Mapped Types
- 5. Built-in Utility Types
- Conclusion
- References
1. Union Types
1.1 Definition & Syntax
A Union Type represents a value that can be one of several types. It’s defined using the | (pipe) operator between types.
Syntax:
type UnionType = Type1 | Type2 | Type3;
For example, a variable that can be a string or number:
type StringOrNumber = string | number;
let value: StringOrNumber;
value = "hello"; // ✅ Valid
value = 42; // ✅ Valid
value = true; // ❌ Error: Type 'boolean' is not assignable to 'StringOrNumber'
1.2 Use Cases
Unions shine when a value might come in multiple forms. Common scenarios include:
Example 1: Function Parameters with Multiple Types
A function that formats a value as a string, whether it’s a Date or a raw string:
type FormatInput = string | Date;
function formatDate(input: FormatInput): string {
if (typeof input === "string") {
return new Date(input).toISOString(); // Handle string input
} else {
return input.toISOString(); // Handle Date input
}
}
console.log(formatDate("2024-01-01")); // "2024-01-01T00:00:00.000Z"
console.log(formatDate(new Date())); // Current date as ISO string
Example 2: Discriminated Unions (Tagged Unions)
For complex unions, add a “discriminant” property (e.g., type: "user" or type: "admin") to simplify narrowing:
type User = { type: "user"; name: string };
type Admin = { type: "admin"; name: string; permissions: string[] };
type Person = User | Admin;
function greet(person: Person): string {
if (person.type === "user") {
return `Hello, ${person.name}!`; // Narrowed to User
} else {
return `Hello, Admin ${person.name}! Permissions: ${person.permissions.join(", ")}`; // Narrowed to Admin
}
}
1.3 Narrowing Union Types with Type Guards
Unions are flexible, but TypeScript needs help “narrowing” the type to a specific member (e.g., distinguishing string from number in string | number). Type Guards are functions or checks that tell TypeScript the specific type of a value.
Common Type Guards:
-
typeofGuard: For primitives (string,number,boolean,symbol).function logValue(value: string | number): void { if (typeof value === "string") { console.log("String length:", value.length); // TypeScript knows value is string here } else { console.log("Number doubled:", value * 2); // TypeScript knows value is number here } } -
instanceofGuard: For class instances.class Dog { bark() {} } class Cat { meow() {} } type Pet = Dog | Cat; function makeSound(pet: Pet): void { if (pet instanceof Dog) { pet.bark(); // Narrowed to Dog } else { pet.meow(); // Narrowed to Cat } } -
User-Defined Type Guards: For custom types (e.g., objects). Use the
iskeyword to return a type predicate.type Fish = { swim: () => void }; type Bird = { fly: () => void }; // Type guard: Returns true if 'pet' is a Fish function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; // Check for Fish-specific property } function move(pet: Fish | Bird): void { if (isFish(pet)) { pet.swim(); // Narrowed to Fish } else { pet.fly(); // Narrowed to Bird } }
2. Intersection Types
2.1 Definition & Syntax
An Intersection Type combines multiple types into one, requiring a value to have all properties/members of each type. It’s defined using the & (ampersand) operator.
Syntax:
type IntersectionType = Type1 & Type2 & Type3;
For example, merging User and Contact types:
type User = { id: number; name: string };
type Contact = { email: string; phone: string };
type UserWithContact = User & Contact; // { id: number; name: string; email: string; phone: string }
const user: UserWithContact = {
id: 1,
name: "Alice",
email: "[email protected]",
phone: "555-1234"
}; // ✅ Valid (has all properties of User and Contact)
2.2 Use Cases
Intersections are ideal for:
- Merging interfaces or types (e.g., combining user data with metadata).
- Adding properties to existing types without modifying them.
Example: Merging Configuration Types
type BaseConfig = { apiUrl: string; timeout: number };
type AuthConfig = { apiKey: string };
type FullConfig = BaseConfig & AuthConfig; // Requires all properties from both
const config: FullConfig = {
apiUrl: "https://api.example.com",
timeout: 5000,
apiKey: "secret-key"
};
2.3 Pitfalls: Conflicting Properties
If two types in an intersection have a property with conflicting types, the result is never (TypeScript can’t resolve the conflict):
type A = { id: string };
type B = { id: number };
type C = A & B; // { id: never } (string & number is never)
const c: C = { id: "123" }; // ❌ Error: Type 'string' is not assignable to 'never'
3. Conditional Types
Conditional Types let you define types based on a condition (T extends U ? X : Y). They’re like “type-level if statements” and are often used to create reusable, dynamic types.
3.1 Syntax & Basic Behavior
Syntax:
type ConditionalType<T> = T extends U ? X : Y;
- If
Tis assignable toU, the type isX; otherwise, it’sY.
Example: Simple Conditional Type
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes" (string extends string)
type B = IsString<number>; // "no" (number does not extend string)
type C = IsString<string | number>; // "yes" | "no" (distributive over unions)
3.2 Advanced: infer Keyword
The infer keyword lets you “extract” a type from another type within a conditional. It’s powerful for tasks like extracting return types or parameter types of functions.
Example 1: Extract Return Type of a Function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Func = () => string;
type FuncReturnType = ReturnType<Func>; // string (inferred R is string)
type AsyncFunc = () => Promise<number>;
type AsyncReturnType = ReturnType<AsyncFunc>; // Promise<number>
Example 2: Extract Array Element Type
type ArrayElement<T> = T extends (infer E)[] ? E : T;
type Numbers = number[];
type NumberElement = ArrayElement<Numbers>; // number (inferred E is number)
type NotAnArray = string;
type NotAnArrayElement = ArrayElement<NotAnArray>; // string (falls back to T)
4. Mapped Types
Mapped Types let you iterate over the properties of an existing type (T) and transform each property to create a new type. They’re used to create utilities like Readonly<T> or Partial<T>.
4.1 Definition & Syntax
Syntax:
type MappedType<T> = {
[P in keyof T]: Transform<T[P]>; // Iterate over each property P in T
};
keyof Tgets all property keys ofT(e.g.,keyof { a: number; b: string }is"a" | "b").initerates over each key inkeyof T.
4.2 Practical Examples
Example 1: Make All Properties Readonly
Recreating TypeScript’s built-in Readonly<T>:
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]; // Add 'readonly' modifier to each property
};
type User = { name: string; age: number };
type ReadonlyUser = MyReadonly<User>; // { readonly name: string; readonly age: number }
const user: ReadonlyUser = { name: "Bob", age: 30 };
user.name = "Alice"; // ❌ Error: Cannot assign to 'name' because it is a read-only property
Example 2: Make Properties Optional
Recreating Partial<T> (covered in Utility Types below):
type MyPartial<T> = {
[P in keyof T]?: T[P]; // Add 'optional' modifier (?) to each property
};
type User = { name: string; age: number };
type PartialUser = MyPartial<User>; // { name?: string; age?: number }
const partialUser: PartialUser = { name: "Charlie" }; // ✅ Valid (age is optional)
Example 3: Transform Property Types
Map each property to a function that returns the original type:
type ToFunction<T> = {
[P in keyof T]: () => T[P]; // Each property becomes a function returning T[P]
};
type Data = { id: number; name: string };
type DataGetters = ToFunction<Data>; // { id: () => number; name: () => string }
const getters: DataGetters = {
id: () => 1,
name: () => "Alice"
};
5. Built-in Utility Types
TypeScript provides a set of built-in Utility Types that leverage advanced types (unions, intersections, mapped types, conditionals) to solve common problems. Here are the most useful ones:
5.1 Partial<T> & Required<T>
-
Partial<T>: Makes all properties ofToptional.interface Todo { title: string; completed: boolean; } type PartialTodo = Partial<Todo>; // { title?: string; completed?: boolean } // Use case: Updating an object with partial data function updateTodo(todo: Todo, changes: Partial<Todo>): Todo { return { ...todo, ...changes }; } const todo: Todo = { title: "Learn TS", completed: false }; const updated = updateTodo(todo, { completed: true }); // ✅ { title: "Learn TS", completed: true } -
Required<T>: The opposite ofPartial<T>—makes all properties required.type OptionalTodo = Partial<Todo>; type RequiredTodo = Required<OptionalTodo>; // { title: string; completed: boolean }
5.2 Readonly<T>
Makes all properties of T readonly.
type ReadonlyTodo = Readonly<Todo>; // { readonly title: string; readonly completed: boolean }
const todo: ReadonlyTodo = { title: "Read", completed: false };
todo.title = "Write"; // ❌ Error: Readonly property
5.3 Pick<T, K> & Omit<T, K>
-
Pick<T, K>: Selects a subset of propertiesKfromT.type TodoTitle = Pick<Todo, "title">; // { title: string } -
Omit<T, K>: Removes propertiesKfromT(opposite ofPick).type TodoWithoutTitle = Omit<Todo, "title">; // { completed: boolean }
5.4 Exclude<T, U> & Extract<T, U>
These work with union types:
-
Exclude<T, U>: Removes types fromTthat are assignable toU.type T = "a" | "b" | "c"; type U = "a" | "d"; type Excluded = Exclude<T, U>; // "b" | "c" (removes "a" because "a" extends U) -
Extract<T, U>: Keeps types fromTthat are assignable toU.type Extracted = Extract<T, U>; // "a" (keeps "a" because "a" extends U))
Conclusion
Advanced types in TypeScript—Unions, Intersections, Conditional Types, Mapped Types, and Utility Types—unlock powerful ways to model complex data and logic. By mastering these tools, you’ll write more expressive, type-safe code that catches errors early and scales with your application’s needs.
Start small: use unions for flexible parameters, intersections to merge types, and utility types like Partial or Pick to simplify common tasks. As you grow, experiment with conditional and mapped types to build custom type utilities tailored to your project.