Use when similar types have minor differences. Use when union types become complex. Use when choosing between modeling differences.
Minor differences often don't warrant separate types.
When types are almost identical, unifying them simplifies your code. The cost of handling small differences is usually less than maintaining parallel type hierarchies.
Unify types unless differences are fundamental.
Small variations can be handled at runtime.
Remember:
// Two types that are almost identical
interface Dog {
name: string;
breed: string;
barkVolume: number;
}
interface Cat {
name: string;
breed: string;
meowVolume: number;
}
type Pet = Dog | Cat;
// Every function needs to narrow:
function getPetInfo(pet: Pet): string {
if ('barkVolume' in pet) {
return `${pet.name} barks at ${pet.barkVolume}`;
} else {
return `${pet.name} meows at ${pet.meowVolume}`;
}
}
interface Pet {
name: string;
breed: string;
vocalizationType: 'bark' | 'meow';
vocalizationVolume: number;
}
function getPetInfo(pet: Pet): string {
return `${pet.name} ${pet.vocalizationType}s at ${pet.vocalizationVolume}`;
}
No narrowing needed. One type handles both cases.
// These really are different
interface File {
path: string;
read(): Buffer;
write(data: Buffer): void;
}
interface Directory {
path: string;
list(): string[];
create(name: string): void;
}
Files and directories have different operations. Unifying would lose type safety.
// Different structures
interface SingleResult {
value: number;
}
interface MultipleResults {
values: number[];
average: number;
}
These have genuinely different shapes.
// Separate types
interface SuccessResponse {
status: 'success';
data: Data;
timestamp: Date;
}
interface ErrorResponse {
status: 'error';
error: string;
timestamp: Date;
}
type Response = SuccessResponse | ErrorResponse;
// Handling requires narrowing everywhere
function logResponse(res: Response) {
console.log(`${res.timestamp}: ${res.status}`);
if (res.status === 'success') {
console.log(res.data); // Need to narrow
}
}
Consider if unified is simpler:
// Unified type
interface Response {
status: 'success' | 'error';
data?: Data; // Present on success
error?: string; // Present on error
timestamp: Date;
}
// Can access common fields without narrowing
function logResponse(res: Response) {
console.log(`${res.timestamp}: ${res.status}`);
if (res.data) {
console.log(res.data);
}
}
Sometimes a tagged union is the right balance:
// Tagged union: explicit about differences, unified handling
interface Animal {
name: string;
breed: string;
}
interface Dog extends Animal {
type: 'dog';
barkVolume: number;
}
interface Cat extends Animal {
type: 'cat';
meowVolume: number;
}
type Pet = Dog | Cat;
// Common operations don't need narrowing
function getName(pet: Pet): string {
return pet.name; // Works for both
}
// Type-specific operations are explicit
function getVolume(pet: Pet): number {
return pet.type === 'dog' ? pet.barkVolume : pet.meowVolume;
}
Before creating separate types, ask:
How often do I need type-specific behavior?
Are the differences structural or semantic?
Will unifying lose important type safety?
// Over-differentiated
interface ClickEvent {
type: 'click';
x: number;
y: number;
target: Element;
}
interface KeyEvent {
type: 'key';
key: string;
target: Element;
}
interface ScrollEvent {
type: 'scroll';
scrollTop: number;
target: Element;
}
Could unify common parts:
interface BaseEvent {
type: string;
target: Element;
}
interface ClickEvent extends BaseEvent {
type: 'click';
x: number;
y: number;
}
// etc.
Or fully unify if differences rarely matter:
interface UIEvent {
type: 'click' | 'key' | 'scroll';
target: Element;
details: ClickDetails | KeyDetails | ScrollDetails;
}
Pressure: "Separate types are more accurate"
Response: Precision has a cost in code complexity.
Action: Weigh precision benefits against handling complexity.
Pressure: "Keep them separate for future flexibility"
Response: That's YAGNI. Refactor if they actually diverge.
Action: Unify now; separate later if needed.
| Excuse | Reality |
|---|---|
| "They're conceptually different" | Code doesn't care about concepts |
| "Separate types are cleaner" | More types = more handling code |
| "We might need the distinction" | Cross that bridge when you come to it |
// DON'T: Separate types for minor differences
interface Dog { name: string; bark: () => void; }
interface Cat { name: string; meow: () => void; }
type Pet = Dog | Cat;
// DO: Unified type
interface Pet {
name: string;
sound: 'bark' | 'meow';
makeSound: () => void;
}
// DO: Keep separate when genuinely different
interface File { read(): Buffer; write(b: Buffer): void; }
interface Directory { list(): string[]; }
Unify types unless differences are fundamental.
Separate types mean separate handling code everywhere. When types share most properties and differ only in details, a unified type with optional or variant fields is often simpler.
Based on "Effective TypeScript" by Dan Vanderkam, Item 39: Prefer Unifying Types to Modeling Differences.