Type Inference & Combining
Leverage automatic type detection and create flexible types through unions, aliases, and keyof operators.
Type Inference & Combining Types
Master TypeScript's type inference and type combination strategies with free flashcards and spaced repetition practice. This lesson covers how TypeScript automatically infers types, union types, intersection types, and advanced type composition techniques—essential concepts for writing maintainable, type-safe code without excessive annotations.
Welcome to Type Inference & Combining Types 💻
TypeScript's type system shines brightest when you understand how the compiler infers types and how to combine types effectively. Rather than manually annotating every variable, TypeScript intelligently determines types based on context. When single types aren't enough, you can combine them using unions, intersections, and other powerful operators.
This lesson will transform how you think about types—from static annotations to dynamic, composable building blocks that make your code both flexible and safe.
Core Concept 1: Type Inference Fundamentals 🧠
Type inference is TypeScript's ability to automatically determine types without explicit annotations. The compiler analyzes your code's structure, values, and context to deduce the most appropriate types.
How Inference Works
TypeScript infers types at several key moments:
- Variable initialization - The type of the initial value becomes the variable's type
- Function returns - Based on what the function returns
- Contextual typing - Based on where a value is used
- Best common type - When multiple types are present
// Variable inference
let message = "Hello"; // inferred as string
let count = 42; // inferred as number
let isActive = true; // inferred as boolean
// Array inference
let numbers = [1, 2, 3]; // inferred as number[]
let mixed = [1, "two"]; // inferred as (string | number)[]
💡 Pro Tip: Hover over variables in your IDE to see inferred types—this is invaluable for understanding what TypeScript "sees".
Function Return Type Inference
TypeScript infers return types by analyzing all return statements:
function add(a: number, b: number) {
return a + b; // return type inferred as number
}
function getUser(id: number) {
if (id > 0) {
return { id, name: "Alice" }; // inferred as { id: number; name: string }
}
return null; // function returns { id: number; name: string } | null
}
⚠️ Important: While return type inference is convenient, explicitly annotating function returns often improves error messages and prevents accidental API changes.
Contextual Typing
TypeScript uses context to infer types when values appear in specific positions:
window.addEventListener("click", (event) => {
// event is inferred as MouseEvent based on the "click" context
console.log(event.clientX);
});
const numbers = [1, 2, 3];
numbers.map((num) => {
// num is inferred as number from the array type
return num * 2;
});
Best Common Type Algorithm
When TypeScript encounters multiple types in an array or expression, it finds the most specific type that encompasses all values:
let values = [1, 2, null]; // inferred as (number | null)[]
class Animal { name: string = ""; }
class Dog extends Animal { breed: string = ""; }
class Cat extends Animal { meow() {} }
let pets = [new Dog(), new Cat()]; // inferred as (Dog | Cat)[]
// Note: TypeScript doesn't infer Animal[] unless explicitly told
Core Concept 2: Union Types 🔀
Union types represent values that can be one of several types. They're created using the pipe operator |.
let id: string | number;
id = "ABC123"; // valid
id = 456; // valid
id = true; // Error: boolean not in union
function formatOutput(value: string | number | boolean): string {
return String(value);
}
Working with Union Types
When you have a union type, you can only access members that exist on all types in the union:
function printId(id: string | number) {
console.log(id.toString()); // OK - both types have toString()
console.log(id.toUpperCase()); // Error - number doesn't have toUpperCase()
}
Type Narrowing with Unions
Type narrowing is the process of refining union types to more specific types within conditional blocks:
function processValue(value: string | number) {
if (typeof value === "string") {
// TypeScript narrows value to string here
console.log(value.toUpperCase());
} else {
// TypeScript narrows value to number here
console.log(value.toFixed(2));
}
}
// Narrowing with truthiness
function printName(name: string | null | undefined) {
if (name) {
// name is narrowed to string (truthy)
console.log(name.toUpperCase());
} else {
console.log("No name provided");
}
}
// Narrowing with instanceof
function handleError(error: Error | string) {
if (error instanceof Error) {
console.log(error.message);
} else {
console.log(error);
}
}
Discriminated Unions
A powerful pattern using a common property (discriminant) to distinguish between union members:
interface Circle {
kind: "circle"; // discriminant property
radius: number;
}
interface Square {
kind: "square"; // discriminant property
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
case "square":
// TypeScript knows shape is Square here
return shape.sideLength ** 2;
}
}
💡 Memory Device: Think of unions as "OR" logic—a value can be Type A OR Type B OR Type C.
Core Concept 3: Intersection Types 🔺
Intersection types combine multiple types into one, requiring a value to satisfy all constituent types. They're created using the ampersand operator &.
interface HasName {
name: string;
}
interface HasAge {
age: number;
}
type Person = HasName & HasAge;
const person: Person = {
name: "Alice",
age: 30
// Must have BOTH name and age
};
When to Use Intersections
Intersections are ideal for:
- Combining multiple interfaces
- Adding properties to existing types
- Mixin patterns
// Combining interfaces
interface Clickable {
click(): void;
}
interface Hoverable {
hover(): void;
}
type InteractiveElement = Clickable & Hoverable;
const button: InteractiveElement = {
click() { console.log("Clicked"); },
hover() { console.log("Hovering"); }
};
// Adding properties to existing types
type WithTimestamp<T> = T & { timestamp: Date };
interface User {
id: number;
name: string;
}
type TimestampedUser = WithTimestamp<User>;
const user: TimestampedUser = {
id: 1,
name: "Bob",
timestamp: new Date()
};
Intersections vs. Unions
| Aspect | Union (A | B) | Intersection (A & B) |
|---|---|---|
| Logic | OR - can be A or B | AND - must be both A and B |
| Properties | Only common properties accessible | All properties from both types required |
| Use Case | Multiple possible types | Combining multiple contracts |
| Example | string | number | Named & Aged |
Intersection with Conflicting Types
⚠️ Watch out: Intersecting primitive types creates the never type:
type Impossible = string & number; // never - can't be both!
function test(value: Impossible) {
// This function can never be called - no value satisfies both string AND number
}
However, intersecting object types merges their properties:
interface A { x: number; }
interface B { y: string; }
type C = A & B; // { x: number; y: string; }
💡 Pro Tip: Use intersections to build complex types from simple, reusable building blocks—following the composition over inheritance principle.
Core Concept 4: Type Aliases vs Interfaces 📝
Both type and interface can define object shapes, but they have important differences:
// Type alias
type Point = {
x: number;
y: number;
};
// Interface
interface Point2D {
x: number;
y: number;
}
Key Differences
| Feature | Type Alias | Interface |
|---|---|---|
| Extending | Uses & (intersection) | Uses extends keyword |
| Declaration Merging | ❌ Not supported | ✅ Supported |
| Primitives/Unions | ✅ Can represent any type | ❌ Only object shapes |
| Computed Properties | ✅ Supported | ❌ Limited support |
| Use Case | Complex types, unions, utilities | Object contracts, public APIs |
Declaration Merging with Interfaces
Interfaces with the same name automatically merge:
interface User {
name: string;
}
interface User {
age: number;
}
// Both declarations merge into one:
const user: User = {
name: "Alice",
age: 30
};
When to Use Which?
Use type when:
- Defining unions or intersections
- Creating utility types with mapped types
- Aliasing primitive types
- Using complex conditional types
Use interface when:
- Defining object shapes for public APIs
- You need declaration merging
- Defining class contracts
- Building extensible type hierarchies
// Types are better for unions
type Status = "pending" | "approved" | "rejected";
type Result = Success | Error;
// Interfaces are better for object contracts
interface Repository<T> {
getAll(): Promise<T[]>;
getById(id: string): Promise<T>;
save(item: T): Promise<void>;
}
Detailed Example 1: Building a Form Validation System 📋
Let's combine type inference, unions, and intersections to create a robust form validation system:
// Base field types
interface BaseField {
name: string;
label: string;
required: boolean;
}
// Specific field types
interface TextField extends BaseField {
type: "text";
maxLength?: number;
}
interface NumberField extends BaseField {
type: "number";
min?: number;
max?: number;
}
interface SelectField extends BaseField {
type: "select";
options: string[];
}
// Discriminated union of all field types
type FormField = TextField | NumberField | SelectField;
// Validation result types
type ValidationSuccess = {
valid: true;
value: string | number;
};
type ValidationError = {
valid: false;
errors: string[];
};
type ValidationResult = ValidationSuccess | ValidationError;
// Type guard function
function isValidationError(result: ValidationResult): result is ValidationError {
return !result.valid;
}
// Validation function using type narrowing
function validateField(field: FormField, value: any): ValidationResult {
const errors: string[] = [];
// Check required
if (field.required && !value) {
errors.push(`${field.label} is required`);
return { valid: false, errors };
}
// Type-specific validation using discriminant
switch (field.type) {
case "text":
if (field.maxLength && value.length > field.maxLength) {
errors.push(`${field.label} must be ${field.maxLength} characters or less`);
}
break;
case "number":
const num = Number(value);
if (isNaN(num)) {
errors.push(`${field.label} must be a number`);
}
if (field.min !== undefined && num < field.min) {
errors.push(`${field.label} must be at least ${field.min}`);
}
if (field.max !== undefined && num > field.max) {
errors.push(`${field.label} must be at most ${field.max}`);
}
break;
case "select":
if (!field.options.includes(value)) {
errors.push(`${field.label} must be one of: ${field.options.join(", ")}`);
}
break;
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true, value };
}
// Usage
const nameField: TextField = {
type: "text",
name: "username",
label: "Username",
required: true,
maxLength: 20
};
const result = validateField(nameField, "john_doe");
if (isValidationError(result)) {
console.error(result.errors);
} else {
console.log("Valid value:", result.value);
}
What's happening here:
- Discriminated unions (
FormField) allow different field configurations - Type narrowing in the switch statement gives us type-specific properties
- Union types for
ValidationResulthandle success/error states elegantly - Type guards (
isValidationError) enable safe type narrowing in consuming code - Type inference automatically determines types for variables and returns
Detailed Example 2: API Response Handler with Generics 🌐
Combining intersection types with generics creates powerful, reusable type utilities:
// Base API response structure
interface ApiResponse {
status: number;
timestamp: Date;
}
// Success and error response types
interface SuccessResponse<T> extends ApiResponse {
success: true;
data: T;
}
interface ErrorResponse extends ApiResponse {
success: false;
error: {
code: string;
message: string;
};
}
// Union type for all responses
type Response<T> = SuccessResponse<T> | ErrorResponse;
// Add metadata to any type using intersection
type WithMetadata<T> = T & {
metadata: {
requestId: string;
duration: number;
};
};
// Combine both patterns
type EnhancedResponse<T> = WithMetadata<Response<T>>;
// Type guard for success responses
function isSuccess<T>(response: Response<T>): response is SuccessResponse<T> {
return response.success === true;
}
// Generic handler function
function handleResponse<T>(
response: EnhancedResponse<T>,
onSuccess: (data: T) => void,
onError: (error: ErrorResponse["error"]) => void
) {
console.log(`Request ${response.metadata.requestId} took ${response.metadata.duration}ms`);
if (isSuccess(response)) {
// TypeScript knows response.data exists here
onSuccess(response.data);
} else {
// TypeScript knows response.error exists here
onError(response.error);
}
}
// Usage with inferred types
interface User {
id: number;
name: string;
email: string;
}
const userResponse: EnhancedResponse<User> = {
success: true,
status: 200,
timestamp: new Date(),
data: {
id: 1,
name: "Alice",
email: "alice@example.com"
},
metadata: {
requestId: "abc-123",
duration: 145
}
};
handleResponse(
userResponse,
(user) => {
// user is inferred as User type
console.log(`Welcome, ${user.name}!`);
},
(error) => {
console.error(`Error ${error.code}: ${error.message}`);
}
);
Key techniques demonstrated:
- Generic types (
Response<T>) make the pattern reusable for any data type - Intersection types (
WithMetadata<T>) add properties without modifying original types - Type composition creates
EnhancedResponse<T>from smaller building blocks - Type guards enable safe access to discriminated union properties
- Type inference in callbacks automatically provides correct parameter types
Detailed Example 3: Event System with Mapped Types 🎯
Let's build a type-safe event system combining multiple type techniques:
// Define event payload types
interface UserEvents {
login: { userId: string; timestamp: Date };
logout: { userId: string };
profileUpdate: { userId: string; fields: string[] };
}
interface SystemEvents {
error: { code: string; message: string };
warning: { message: string };
}
// Combine all events using intersection
type AppEvents = UserEvents & SystemEvents;
// Type-safe event handler signature
type EventHandler<T> = (payload: T) => void;
// Mapped type to create handler registry
type EventHandlers = {
[K in keyof AppEvents]?: EventHandler<AppEvents[K]>[];
};
// Event emitter class
class EventEmitter {
private handlers: EventHandlers = {};
// Type-safe registration with constraint
on<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
): void {
if (!this.handlers[event]) {
this.handlers[event] = [];
}
this.handlers[event]!.push(handler);
}
// Type-safe emission
emit<K extends keyof AppEvents>(
event: K,
payload: AppEvents[K]
): void {
const eventHandlers = this.handlers[event];
if (eventHandlers) {
eventHandlers.forEach(handler => handler(payload));
}
}
// Remove handler
off<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
): void {
const eventHandlers = this.handlers[event];
if (eventHandlers) {
const index = eventHandlers.indexOf(handler);
if (index > -1) {
eventHandlers.splice(index, 1);
}
}
}
}
// Usage with full type safety
const emitter = new EventEmitter();
// TypeScript knows the exact payload type for each event
emitter.on("login", (payload) => {
// payload is inferred as { userId: string; timestamp: Date }
console.log(`User ${payload.userId} logged in at ${payload.timestamp}`);
});
emitter.on("error", (payload) => {
// payload is inferred as { code: string; message: string }
console.error(`Error ${payload.code}: ${payload.message}`);
});
// Emit events with type checking
emitter.emit("login", {
userId: "user123",
timestamp: new Date()
});
// This would cause a type error:
// emitter.emit("login", { userId: "user123" }); // Missing timestamp!
// emitter.emit("unknown", {}); // Event doesn't exist!
Advanced patterns used:
- Intersection of interfaces combines multiple event maps
- Mapped types (
EventHandlers) transform keys into handler arrays - Generic constraints (
K extends keyof AppEvents) ensure only valid event names - Indexed access types (
AppEvents[K]) extract specific event payloads - Type inference in callbacks provides exact payload types automatically
Detailed Example 4: State Machine with Literal Types 🔄
Combining literal types, discriminated unions, and type narrowing creates robust state machines:
// State definitions using discriminated unions
type IdleState = {
status: "idle";
};
type LoadingState = {
status: "loading";
progress: number;
};
type SuccessState<T> = {
status: "success";
data: T;
loadedAt: Date;
};
type ErrorState = {
status: "error";
error: string;
retryCount: number;
};
// Union of all possible states
type State<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;
// Valid state transitions
type Transitions<T> = {
idle: LoadingState;
loading: SuccessState<T> | ErrorState;
success: IdleState | LoadingState;
error: IdleState | LoadingState;
};
// State machine class
class StateMachine<T> {
private state: State<T>;
constructor() {
this.state = { status: "idle" };
}
getState(): State<T> {
return this.state;
}
// Type-safe transitions using discriminated unions
transition(newState: State<T>): void {
// Validate transition (simplified)
const currentStatus = this.state.status;
const newStatus = newState.status;
console.log(`Transitioning from ${currentStatus} to ${newStatus}`);
this.state = newState;
}
// Helper methods with type narrowing
isLoading(): this is { state: LoadingState } {
return this.state.status === "loading";
}
isSuccess(): this is { state: SuccessState<T> } {
return this.state.status === "success";
}
isError(): this is { state: ErrorState } {
return this.state.status === "error";
}
// Type-safe data access
getData(): T | null {
if (this.state.status === "success") {
// TypeScript knows this.state.data exists here
return this.state.data;
}
return null;
}
}
// Usage example
interface ApiData {
users: string[];
count: number;
}
const machine = new StateMachine<ApiData>();
// Start loading
machine.transition({ status: "loading", progress: 0 });
// Simulate progress
setTimeout(() => {
machine.transition({ status: "loading", progress: 50 });
}, 1000);
// Complete successfully
setTimeout(() => {
machine.transition({
status: "success",
data: { users: ["Alice", "Bob"], count: 2 },
loadedAt: new Date()
});
const currentState = machine.getState();
if (currentState.status === "success") {
// Type narrowing ensures data access is safe
console.log(`Loaded ${currentState.data.count} users`);
}
}, 2000);
// Or handle error
function handleError(retries: number) {
machine.transition({
status: "error",
error: "Network timeout",
retryCount: retries
});
}
Design patterns highlighted:
- Literal types for status ensure only valid states exist
- Discriminated unions enable exhaustive state handling
- Generic state machine works with any data type
- Type narrowing provides safe access to state-specific properties
- Type guards (
isLoading(),isSuccess()) simplify conditional logic
Common Mistakes to Avoid ⚠️
Mistake 1: Over-Annotating When Inference Works
❌ Wrong:
const numbers: number[] = [1, 2, 3]; // Unnecessary annotation
const result: number = numbers.reduce((a: number, b: number): number => a + b, 0);
✅ Right:
const numbers = [1, 2, 3]; // Inferred as number[]
const result = numbers.reduce((a, b) => a + b, 0); // Everything inferred
Mistake 2: Confusing Union and Intersection
❌ Wrong:
// Trying to use intersection when union is needed
function print(value: string & number) {
// This is 'never' - impossible type!
console.log(value);
}
✅ Right:
// Use union for "either-or" scenarios
function print(value: string | number) {
console.log(value.toString());
}
Mistake 3: Not Narrowing Union Types
❌ Wrong:
function getLength(value: string | number) {
return value.length; // Error: number doesn't have length
}
✅ Right:
function getLength(value: string | number) {
if (typeof value === "string") {
return value.length;
}
return value.toString().length;
}
Mistake 4: Missing Discriminant in Unions
❌ Wrong:
interface Cat { meow(): void; }
interface Dog { bark(): void; }
type Pet = Cat | Dog;
function makeSound(pet: Pet) {
// How do we know which type it is?
pet.meow(); // Error: might be Dog
}
✅ Right:
interface Cat { type: "cat"; meow(): void; }
interface Dog { type: "dog"; bark(): void; }
type Pet = Cat | Dog;
function makeSound(pet: Pet) {
if (pet.type === "cat") {
pet.meow(); // Safe!
} else {
pet.bark(); // Safe!
}
}
Mistake 5: Incorrect Cloze Deletion in Types
❌ Wrong:
// Template that doesn't match when filled
type Result = {{1}} string | Error; // Answer: "Success<T> |"
// Produces: "Success<T> | string | Error" (extra tokens!)
✅ Right:
type Result = {{1}} | Error; // Answer: "Success<T> | string"
// Produces: "Success<T> | string | Error" (exact match!)
Mistake 6: Using Any Instead of Unknown for Unions
❌ Wrong:
function process(value: any) {
// No type safety at all
return value.toUpperCase();
}
✅ Right:
function process(value: unknown) {
if (typeof value === "string") {
return value.toUpperCase();
}
return String(value);
}
Key Takeaways 🎯
Type inference reduces boilerplate - Let TypeScript infer types when it's obvious from context, but annotate when it improves clarity or catches errors
Union types represent alternatives - Use
A | Bwhen a value can be one type OR another, and always narrow before accessing type-specific propertiesIntersection types combine requirements - Use
A & Bwhen a value must satisfy both type A AND type B simultaneouslyDiscriminated unions are powerful - Add a common literal property to union members to enable exhaustive type narrowing
Type narrowing is essential - Use typeof, instanceof, property checks, and type guards to refine union types to specific members
Choose the right tool - Use
typefor unions, intersections, and utilities; useinterfacefor object contracts and extensible APIsComposition over complexity - Build complex types from simple, reusable building blocks using unions and intersections
Generic constraints enhance safety - Use
extends keyofand indexed access types to create type-safe, flexible APIs
📋 Quick Reference Card
| Concept | Syntax | Use Case |
|---|---|---|
| Union Type | A | B | C | Value can be one of several types |
| Intersection Type | A & B & C | Value must satisfy all types |
| Type Inference | let x = 5 | Automatic type from value |
| Type Narrowing | typeof x === "string" | Refine union to specific type |
| Type Alias | type Name = Type | Name for any type expression |
| Interface | interface Name { } | Object contract definition |
| Discriminant | { kind: "circle" } | Property to distinguish unions |
| Type Guard | is Type | Function that narrows types |
| Generic Constraint | T extends Key | Limit generic parameter |
| Indexed Access | T[K] | Extract property type |
Further Study 📚
- TypeScript Handbook - Type Inference: https://www.typescriptlang.org/docs/handbook/type-inference.html
- TypeScript Handbook - Unions and Intersections: https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types
- Advanced TypeScript Patterns: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
Mastering type inference and type combination unlocks TypeScript's true power—creating flexible, maintainable code with minimal annotations and maximum safety. Practice these patterns in real projects to internalize when to use unions versus intersections, and how to leverage inference for cleaner code! 🚀