Interface vs Type
When to use interface, when to use type, declaration merging, and extending vs intersecting. You'll hit this when you're unsure which to pick and whether it actually matters.
type User = {
name: string;
role: string;
};
// Intersection: silently merges conflicts
type AdminUser = User & {
role: number; // No error here!
};
// role is 'string & number' which is 'never'
// You only find out when you try to use it
const admin: AdminUser = {
name: "Alice",
role: "admin", // Error: never
};type User = {
name: string;
role: string;
};
// Intersection: silently merges conflicts
type AdminUser = User & {
role: number; // No error here!
};
// role is 'string & number' which is 'never'
// You only find out when you try to use it
const admin: AdminUser = {
name: "Alice",
role: "admin", // Error: never
};interface User {
name: string;
role: string;
}
// Extends: catches conflicts immediately
// interface AdminUser extends User {
// role: number; // Error: not assignable
// }
interface AdminUser extends User {
permissions: string[];
}
const admin: AdminUser = {
name: "Alice",
role: "admin", // OK
permissions: ["read"],
};interface User {
name: string;
role: string;
}
// Extends: catches conflicts immediately
// interface AdminUser extends User {
// role: number; // Error: not assignable
// }
interface AdminUser extends User {
permissions: string[];
}
const admin: AdminUser = {
name: "Alice",
role: "admin", // OK
permissions: ["read"],
};Type aliases work for object shapes and can be extended with & intersections. However, intersections silently merge conflicting properties into never instead of erroring. Types also cannot be augmented via declaration merging. For plain object shapes, interfaces are the conventional choice.
Interfaces are the idiomatic choice for object shapes. They support clean extends inheritance (which catches property conflicts at declaration), declaration merging for augmenting third-party types, and produce clearer error messages. Both types and interfaces can be extended, but interfaces make the intent more explicit.
// Can't express a union with interface
// interface Result = Success | Failure; // Syntax error
// Workaround: single interface with optionals
interface Result {
success: boolean;
data?: string;
error?: string;
}
// Not type-safe: can have both data and error// Can't express a union with interface
// interface Result = Success | Failure; // Syntax error
// Workaround: single interface with optionals
interface Result {
success: boolean;
data?: string;
error?: string;
}
// Not type-safe: can have both data and errorinterface Success {
status: "ok";
data: string;
}
interface Failure {
status: "error";
error: string;
}
// Union type: only type aliases can do this
type Result = Success | Failure;
// Type-safe: data and error are mutually exclusiveinterface Success {
status: "ok";
data: string;
}
interface Failure {
status: "error";
error: string;
}
// Union type: only type aliases can do this
type Result = Success | Failure;
// Type-safe: data and error are mutually exclusiveA single interface with optional fields cannot express mutual exclusivity. Nothing prevents a value from having both data and error, or neither. A discriminated union with a status field guarantees exactly one variant at a time.
Union types can only be expressed with the type keyword. Interfaces cannot be combined with |. Define each variant as an interface for its object shape, then use a type alias to create the union. This gives you the best of both worlds.
type Animal = {
name: string;
legs: number;
};
type Dog = Animal & {
breed: string;
};
// Intersection issues:
// - Conflicting properties become 'never' silently
type A = { x: number };
type B = { x: string };
type C = A & B; // x is 'never' (number & string)type Animal = {
name: string;
legs: number;
};
type Dog = Animal & {
breed: string;
};
// Intersection issues:
// - Conflicting properties become 'never' silently
type A = { x: number };
type B = { x: string };
type C = A & B; // x is 'never' (number & string)interface Animal {
name: string;
legs: number;
}
interface Dog extends Animal {
breed: string;
}
// Extends catches conflicts at declaration:
// interface Bad extends Animal {
// name: number; // Error: not assignable to string
// }interface Animal {
name: string;
legs: number;
}
interface Dog extends Animal {
breed: string;
}
// Extends catches conflicts at declaration:
// interface Bad extends Animal {
// name: number; // Error: not assignable to string
// }Intersection types silently merge conflicting properties into never. number & string is never because no value can be both. This compiles without error at the type definition, but any attempt to assign a value to x fails. extends catches the conflict immediately.
extends catches property type conflicts at the point of declaration. If you try to override name with an incompatible type, TypeScript immediately reports an error. With &, conflicting properties silently become never, which only causes errors later when you try to use the value.
// Can't merge type aliases
type Window = {
title: string;
};
type Window = {
// Error: Duplicate identifier 'Window'
appVersion: string;
};// Can't merge type aliases
type Window = {
title: string;
};
type Window = {
// Error: Duplicate identifier 'Window'
appVersion: string;
};// Interfaces merge automatically
interface Window {
title: string;
}
interface Window {
appVersion: string;
}
// Result: Window has both title and appVersion
// Useful for augmenting third-party types:
declare global {
interface Window {
analytics: AnalyticsLib;
}
}// Interfaces merge automatically
interface Window {
title: string;
}
interface Window {
appVersion: string;
}
// Result: Window has both title and appVersion
// Useful for augmenting third-party types:
declare global {
interface Window {
analytics: AnalyticsLib;
}
}Type aliases are closed after declaration. Defining the same type alias twice is a compile error. If you need to add properties to an existing type from another module (like Window or a library type), you must use an interface.
Interfaces with the same name in the same scope are automatically merged. This is essential for augmenting global types or extending third-party library types without modifying their source. Type aliases cannot be reopened, so they do not support this pattern.
interface ApiResponse {
user: {
id: string;
profile: {
name: string;
avatar: string;
};
};
posts: { id: string; title: string }[];
}
// Manually duplicating nested types
interface Profile {
name: string;
avatar: string;
}
interface Post {
id: string;
title: string;
}interface ApiResponse {
user: {
id: string;
profile: {
name: string;
avatar: string;
};
};
posts: { id: string; title: string }[];
}
// Manually duplicating nested types
interface Profile {
name: string;
avatar: string;
}
interface Post {
id: string;
title: string;
}interface ApiResponse {
user: {
id: string;
profile: {
name: string;
avatar: string;
};
};
posts: { id: string; title: string }[];
}
// Extract nested types with indexed access
type Profile = ApiResponse["user"]["profile"];
// { name: string; avatar: string }
type Post = ApiResponse["posts"][number];
// { id: string; title: string }interface ApiResponse {
user: {
id: string;
profile: {
name: string;
avatar: string;
};
};
posts: { id: string; title: string }[];
}
// Extract nested types with indexed access
type Profile = ApiResponse["user"]["profile"];
// { name: string; avatar: string }
type Post = ApiResponse["posts"][number];
// { id: string; title: string }Manually writing separate interfaces for nested shapes creates multiple sources of truth. If the API response changes the profile shape, the hand-written Profile interface is silently wrong. Indexed access types keep everything derived from one source.
Indexed access types let you extract nested types from existing types using bracket notation. ApiResponse["user"]["profile"] drills into the structure, and [number] extracts the element type from an array. Changes to the source type automatically propagate.
// Using type for everything
type Props = {
children: React.ReactNode;
onClick: () => void;
};
type Theme = "light" | "dark";
type Merge<A, B> = Omit<A, keyof B> & B;
// No consistency, no reasoning about
// when to use type vs interface// Using type for everything
type Props = {
children: React.ReactNode;
onClick: () => void;
};
type Theme = "light" | "dark";
type Merge<A, B> = Omit<A, keyof B> & B;
// No consistency, no reasoning about
// when to use type vs interface// Interface for object shapes and contracts
interface Props {
children: React.ReactNode;
onClick: () => void;
}
// Type for unions, intersections, and utilities
type Theme = "light" | "dark";
type Merge<A, B> = Omit<A, keyof B> & B;
// Rule of thumb:
// - interface: object shapes, class contracts, extendable APIs
// - type: unions, mapped types, conditional types, computed types// Interface for object shapes and contracts
interface Props {
children: React.ReactNode;
onClick: () => void;
}
// Type for unions, intersections, and utilities
type Theme = "light" | "dark";
type Merge<A, B> = Omit<A, keyof B> & B;
// Rule of thumb:
// - interface: object shapes, class contracts, extendable APIs
// - type: unions, mapped types, conditional types, computed typesUsing type for everything works, but you miss out on extends syntax, declaration merging, and clearer error messages for object shapes. Having a clear convention helps teams make consistent decisions and produces more readable code.
The practical rule: use interface for object shapes you might extend or that represent contracts (props, API responses, class shapes). Use type for unions, computed types, mapped types, and anything that cannot be expressed as an interface. Consistency within a codebase matters more than the choice itself.