cheat sheet
Types vs Interfaces
TypeScript has two ways to define object shapes — type aliases and interface declarations. Learn when each is appropriate, how they differ in extension, merging, and composability.
Types vs Interfaces
What it is
TypeScript has two constructs for naming an object shape: interface declarations and type aliases. They are mostly interchangeable for object types, but each has capabilities the other lacks. Understanding the differences helps you choose the right tool and avoid subtle bugs.
interface declaration
An interface declaration describes the shape of an object — its properties, method signatures, index signatures, and optional or readonly modifiers. Interfaces are open: TypeScript merges multiple declarations with the same name, making them the standard choice for public API shapes and library augmentation.
interface User {
id: number;
name: string;
email?: string; // optional
}
const alice: User = { id: 1, name: "Alice" };
Interfaces describe the shape of an object. They support optional properties (?), readonly modifiers, method signatures, and index signatures.
interface Dictionary {
[key: string]: string;
}
interface Repository<T> {
findById(id: number): Promise<T>;
save(entity: T): Promise<T>;
delete(id: number): Promise<void>;
}
type alias declaration
A type alias gives a name to any type expression — object shapes, unions, tuples, primitives, function types, template literals, and mapped types. Unlike interfaces, type aliases are closed: they cannot be redeclared or merged, but they can express constructs that interfaces cannot.
type User = {
id: number;
name: string;
email?: string;
};
const bob: User = { id: 2, name: "Bob" };
A type alias assigns a name to any type expression — not just object shapes. This is the key distinction from interface.
type ID = string | number; // union
type Pair = [string, number]; // tuple
type Callback = () => void; // function type
type Nullable<T> = T | null; // generic alias
type EventName = `on${string}`; // template literal type
Extending
interface extends
interface extends creates a new interface that inherits all members from one or more parent interfaces. Conflicting property types produce a compile error at the point of declaration, catching mistakes early rather than silently producing never.
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// Multiple inheritance
interface ServiceDog extends Dog, Trained {
certificationId: string;
}
type intersection
The & operator merges all members from two or more types into a single combined type. It is the type-alias equivalent of interface extends, but it defers conflict detection: a conflicting property resolves to never at the intersection rather than raising an error at the definition site.
type Animal = {
name: string;
};
type Dog = Animal & {
breed: string;
};
// Equivalent to interface extends for simple cases
type ServiceDog = Dog & Trained & {
certificationId: string;
};
Key difference in extension
When extending with conflicting property types, interface extends produces a compile error immediately. Type intersections silently produce never for the conflicting property.
interface A { value: string }
interface B extends A { value: number } // Error: 'value' is incompatible
type C = { value: string };
type D = C & { value: number };
// D['value'] is 'string & number' which simplifies to 'never'
// No error at definition — only errors when you try to use it
Declaration merging (interfaces only)
Two interface declarations with the same name in the same scope are automatically merged. This is called declaration merging.
interface Window {
myCustomProp: string;
}
interface Window {
anotherProp: number;
}
// Result: Window has both myCustomProp and anotherProp
const w: Window = { myCustomProp: "hello", anotherProp: 42, .../* rest of Window */ };
This is widely used to augment third-party types, especially for global objects, Express Request, and module augmentation:
// Augmenting Express Request to add a user property
declare global {
namespace Express {
interface Request {
user?: { id: string; role: string };
}
}
}
type aliases cannot be merged — redeclaring the same name is a compile error.
Computed property names and mapped types (types only)
Mapped types require type:
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
type Optional<T> = {
[K in keyof T]?: T[K];
};
Computed property names in interfaces are severely limited — they only work with known literal types. Mapped types are far more powerful and require type.
Unions (types only)
A union type (A | B) describes a value that can be one of several distinct shapes. Only type aliases can express unions — interfaces have no equivalent syntax. Unions are the foundation of discriminated unions, which TypeScript narrows automatically via control-flow analysis on a shared literal property.
// Only possible with type
type Result<T> =
| { status: "ok"; data: T }
| { status: "error"; message: string };
type StringOrNumber = string | number;
function handle(result: Result<User>) {
if (result.status === "ok") {
console.log(result.data.name);
} else {
console.error(result.message);
}
}
Interfaces cannot express union types — you cannot write interface Foo = A | B.
When to prefer interface
- Public API / library surface: Interfaces can be augmented by consumers via declaration merging. Type aliases cannot.
- Object shapes that will be extended: Class
implementsworks identically for both, butinterface extendsgives clearer error messages. - Classes: Use
interfacefor shapes implemented by classes.
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
class UserStore implements Serializable {
serialize() { return JSON.stringify(this); }
deserialize(data: string) { Object.assign(this, JSON.parse(data)); }
}
When to prefer type
- Unions: only possible with
type. - Tuples:
type Point = [number, number]. - Computed / mapped types:
type Mutable<T> = { -readonly [K in keyof T]: T[K] }. - Utility type composition:
type AdminUser = Required<Pick<User, "id" | "role">>. - Template literal types:
type EventKey = `on${Capitalize<string>}`. - Primitives and function types:
type Predicate<T> = (val: T) => boolean.
Gotchas
Excess property checking and type aliases
Both type and interface trigger excess property checking when a literal object is passed directly. However, widening through an intermediate variable bypasses the check in both cases equally.
interface Point { x: number; y: number }
type Point2 = { x: number; y: number };
const p1: Point = { x: 1, y: 2, z: 3 }; // Error — excess property z
const p2: Point2 = { x: 1, y: 2, z: 3 }; // Error — same behavior
const obj = { x: 1, y: 2, z: 3 };
const p3: Point = obj; // OK — fresh literal check bypassed
Class implements type
Both interface and type work with implements:
type Loggable = { log(msg: string): void };
interface Printable { print(): void }
class Console implements Loggable, Printable {
log(msg: string) { console.log(msg); }
print() { console.log("[print]"); }
}
interface cannot implement a union
type Shape = Circle | Square; // union type
class MyShape implements Shape {} // Error — cannot implement union type
Use interface or a single type object shape when using implements.
Quick comparison table
| Feature | interface | type |
|---|---|---|
| Object shape | yes | yes |
| Union types | no | yes |
| Intersection | via extends | via & |
| Tuple | no | yes |
| Primitive alias | no | yes |
| Template literal | no | yes |
| Mapped type | no | yes |
| Declaration merging | yes | no |
implements | yes | yes (non-union) |
| Error messages | Often clearer | Sometimes complex |
Declaration merging — the deep dive
Declaration merging is the single biggest practical difference between interface and type. The same-named interface declarations are unified into one shape; same-named type aliases collide with a TS2300: Duplicate identifier error. This is the reason every published .d.ts file in the ecosystem uses interface for any shape a consumer might want to augment — global types, framework request/response shapes, JSX intrinsic elements, plugin APIs.
// node_modules/express/index.d.ts (simplified)
declare namespace Express {
interface Request {
method: string;
url: string;
}
}
// your-app/types/express.d.ts (augmentation)
declare global {
namespace Express {
interface Request {
user?: { id: string };
requestId: string;
}
}
}
// Now every import of Express.Request sees the combined shape:
// { method: string; url: string; user?: { id: string }; requestId: string }
The runtime behaviour is unchanged — declaration merging is purely a type-system operation. There is no runtime check that requestId exists; the middleware that sets it must run before any handler that reads it.
Merging rules
When two interfaces with the same name are merged:
- Non-function members must have identical types or the merge errors.
- Function members become overloads — every signature from each declaration is added to the overload set.
- Generic parameters must match by name and constraint or the merge errors.
- Later declarations appear first in the overload list, which affects resolution order.
interface Logger {
log(message: string): void;
}
interface Logger {
log(message: string, level: "info" | "warn" | "error"): void;
}
interface Logger {
log(message: string, level: "info" | "warn" | "error", context: object): void;
}
// Merged Logger has all three log() overloads
declare const logger: Logger;
logger.log("hello");
logger.log("hello", "warn");
logger.log("hello", "error", { userId: "u_1" });
Why type aliases cannot merge
A type alias is an entry in TypeScript's type table — a name that points to a single resolved type expression. Allowing two aliases to point to the same name would require the compiler to either pick one or merge them, neither of which has a single sensible answer for unions, tuples, or mapped types.
type Logger = { log(msg: string): void };
type Logger = { warn(msg: string): void };
// Error TS2300: Duplicate identifier 'Logger'
// Even union members can't be added retroactively
type Status = "pending" | "active";
type Status = Status | "deleted"; // Error — circular and duplicate
The closest you can get with type is composition at definition time:
type BaseLogger = { log(msg: string): void };
type FullLogger = BaseLogger & { warn(msg: string): void; error(msg: string): void };
This works for your own code but does not help consumers of a library who want to extend a type you exported. They cannot.
Performance — interfaces type-check faster
For very large object shapes that appear in many places (think framework prop types, ORM record shapes, Apollo GraphQL types), interface is meaningfully faster to type-check than the equivalent type intersection. The reason: interfaces are cached structurally and compared by reference at usage sites, while intersections are recomputed on every reference.
The official TypeScript performance wiki recommends interfaces for any object shape that will be widely referenced. Benchmark numbers vary by codebase, but compile times on a 50k-line project commonly drop 5–15% by switching deep intersection chains to extended interfaces.
// Slower — intersection is re-evaluated at every use site
type A = { a: number };
type B = { b: number };
type C = { c: number };
type Combined = A & B & C & { d: number };
// Faster — extends produces a single cached interface shape
interface A2 { a: number }
interface B2 { b: number }
interface C2 { c: number }
interface Combined2 extends A2, B2, C2 { d: number }
The performance gap is invisible on small projects. It becomes noticeable above ~10k type instantiations and dominant above ~100k. If you have ever waited 30+ seconds for tsc --noEmit, audit your intersection chains first.
extends vs intersection — semantic differences
interface B extends A and type B = A & {} look similar but diverge in three important ways: conflict handling, performance, and assignability for function members.
Conflict handling
interface ParentI { value: string }
interface ChildI extends ParentI {
value: number;
}
// Error TS2430: Interface 'ChildI' incorrectly extends 'ParentI'.
// Types of property 'value' are incompatible.
type ParentT = { value: string };
type ChildT = ParentT & { value: number };
// No error at definition — but ChildT['value'] is `string & number` = `never`
const bad: ChildT = { value: 1 };
// Error: Type 'number' is not assignable to type 'never'.
Interface conflicts surface at the declaration site, where the fix is local. Intersection conflicts surface at the first use site with a confusing never error, often far from the cause.
Function-member variance
interface extends produces a single overloaded function from parent and child signatures. & intersection produces a function type that must accept both parameter types — which usually narrows to never.
interface ParentI {
handle(x: string): void;
}
interface ChildI extends ParentI {
handle(x: number): void;
}
// ChildI.handle has both string and number overloads — narrow parameter types each branch
type ParentT = { handle(x: string): void };
type ChildT = ParentT & { handle(x: number): void };
// ChildT.handle accepts `string & number` = `never` — effectively unusable
For shapes that include function members, prefer interface extends unless you are deliberately building an intersection of unrelated APIs.
implements patterns
implements declares a class conforms to a shape. Both interface and non-union type aliases work with implements, but interfaces are the idiomatic choice and produce clearer error messages.
interface Repository<T> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
return null;
}
async save(entity: User): Promise<User> {
return entity;
}
async delete(id: string): Promise<void> {}
}
Multiple implements:
interface Serializable { serialize(): string }
interface Comparable<T> { compareTo(other: T): number }
class Version implements Serializable, Comparable<Version> {
constructor(public major: number, public minor: number, public patch: number) {}
serialize(): string { return `${this.major}.${this.minor}.${this.patch}` }
compareTo(other: Version): number {
return this.major - other.major
|| this.minor - other.minor
|| this.patch - other.patch;
}
}
A class can implements any non-union type as well:
type Loggable = { log(msg: string): void };
class ConsoleLogger implements Loggable {
log(msg: string) { console.log(msg); }
}
What implements cannot do is implement a union — the class would need to be every variant at once, which is incoherent.
type Animal = { type: "cat"; meow(): void } | { type: "dog"; bark(): void };
class Pet implements Animal {} // Error TS2422: A class can only implement an object type or intersection of object types with statically known members.
The fix is to pick one variant or factor out the shared shape:
type AnimalBase = { type: "cat" | "dog" };
class Pet implements AnimalBase {
type: "cat" | "dog" = "cat";
}
Function types and call signatures
Both interface and type can describe function types, but with subtly different syntax. Function types in type aliases use arrow syntax; interfaces use call signatures inside a body.
// type alias function type
type Comparator<T> = (a: T, b: T) => number;
// interface call signature
interface ComparatorI<T> {
(a: T, b: T): number;
}
// Both are assignable to each other
const byLength: Comparator<string> = (a, b) => a.length - b.length;
const byLengthI: ComparatorI<string> = byLength;
Interfaces shine when a function value also has properties — a hybrid "callable object" pattern that arrow function types cannot express directly:
interface Counter {
(): number;
count: number;
reset(): void;
}
function makeCounter(): Counter {
const fn = (() => ++fn.count) as Counter;
fn.count = 0;
fn.reset = () => { fn.count = 0 };
return fn;
}
const c = makeCounter();
console.log(c()); // 1
console.log(c()); // 2
console.log(c.count); // 2
c.reset();
console.log(c.count); // 0
The equivalent with type requires an intersection:
type CounterT = (() => number) & { count: number; reset(): void };
Both work; the interface form is more readable and the form jQuery, lodash, and similar libraries used historically.
Index signatures
An index signature describes the type of any property keyed by a string, number, or symbol. Both interface and type support index signatures, but the syntax differs slightly and combining with known properties can be tricky.
interface StringMap {
[key: string]: string;
}
interface MixedMap {
length: number; // OK — number is assignable to string-indexed members? NO.
[key: string]: string; // Error TS2411: Property 'length' of type 'number' is not assignable to 'string' index type 'string'.
}
// The fix is a wider value type
interface MixedMapOk {
length: number;
[key: string]: string | number;
}
The same rule applies to type:
type Dict = {
[key: string]: string;
__default: string; // OK — string ⊆ string
};
Symbol and number index signatures coexist with string ones, but every named member must be compatible with the string signature (string indexes encompass numeric and symbol property access too).
interface Cache {
[key: string]: unknown;
[key: number]: number; // Error — number signature value must be subtype of string signature
}
Recursive types
Both interface and type support recursion, but type aliases have historically had restrictions that interfaces did not. As of TS 3.7+ most restrictions are gone for object recursion.
// Interface — always worked
interface TreeNodeI {
value: number;
left?: TreeNodeI;
right?: TreeNodeI;
}
// Type — works since TS 3.7
type TreeNodeT = {
value: number;
left?: TreeNodeT;
right?: TreeNodeT;
};
// JSON value — only possible with `type` (uses union)
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
The JsonValue type is not expressible as an interface — interfaces cannot be a union, only the array and object branches could be interfaces and they'd still need a union somewhere.
Decision tree — which to use
Reaching for the right tool is a quick three-question check:
- Does it need to be a union, tuple, primitive, template literal, mapped, or conditional type? ->
type. - Is it part of a library's public API that consumers may want to augment? ->
interface. - Otherwise (an internal object shape implemented by classes, extended once or twice, used in a few places): either is fine — most teams pick one and stay consistent. The TypeScript team's official guidance leans
interfacefor object shapes because of the performance advantage.
A common house style:
- Use
interfacefor object shapes, especially public-API and class-implemented. - Use
typefor unions, tuples, function types, and any composition that needs utility-type helpers. - Never mix the two for the same logical concept — pick one form per name and never redefine.
Modern TypeScript recommendations
The official TypeScript handbook (post-2020 rewrite) leans toward interface for object shapes by default, with type reserved for cases interfaces cannot express. Two specific recent guidelines:
- Prefer
interfacefor object shapes in published libraries. Consumers can augment via declaration merging; type aliases close this door. - Reach for
typeonly when the construct requires it — unions, tuples, primitives, template literals, mapped types, conditional types, andinfer. Do not switch totype"for consistency" if the shape is plainly an object. - Use
satisfies(TS 4.9+) for object literals you want type-checked without widening. This is orthogonal tointerfacevstype. See the satisfies article for details. - Use
branded typesto recover nominal distinctions in either declaration form. See branded types.
Common pitfalls
- Conflicting declaration merging across files — accidentally re-declaring an interface in two files merges them silently. If you import a type that surprisingly contains members you didn't write, search the codebase for
interface Footo find every contributor. - Re-declaring a
typeand getting a confusing error —Duplicate identifier 'Foo'means there are twotype Fooin the same scope. The fix is to rename one or use composition (type FooFull = FooA & FooB). extendswith a generic that has fewer parameters —interface Foo<T, U> extends Bar<T>works only ifBarhas the right arity. Type aliases are stricter here.- Using
typefor a public API and breaking consumers — once consumers try to augment via merging and fail, they have to monkey-patch with module augmentation tricks. Useinterfacefrom day one for anything youexportfrom a library. - Performance regressions from deep intersections — long
A & B & C & D & E & ...chains slow downtsc. Convert to extended interfaces or factor into named intermediates. - Forgetting
implementsdoes not bring members in —implementsdeclares conformance, not inheritance. The class must still provide every member. Useextendson a base class to inherit implementations. interfacecannot extend a union —interface Foo extends (A | B)is illegal. Pull out the common shape into a base interface and extend that.- Function-member intersection collapsing to
never—({ f(x: string): void }) & ({ f(x: number): void })producesf(x: never): void. Useinterface extendsfor overloads. - Excess property checks bypassed by intermediate variables — both
interfaceandtypebehave the same here, but it surprises developers who think one is "stricter". - Index signature collisions — adding a named member whose type doesn't match the index signature errors. Widen the index signature value type or restructure.
Real-world recipes
Recipe 1 — Library public API with consumer augmentation
A web framework exports a Context interface that handlers receive. Plugins augment it to add their own fields without forking the library.
// my-framework/src/context.ts
export interface Context {
request: Request;
response: Response;
}
// plugin-auth/src/types.ts
declare module "my-framework" {
interface Context {
user?: { id: string; roles: string[] };
}
}
// plugin-tracing/src/types.ts
declare module "my-framework" {
interface Context {
traceId: string;
spanId: string;
}
}
// consumer code sees the merged Context
import type { Context } from "my-framework";
function handler(ctx: Context) {
console.log(ctx.traceId, ctx.user?.id);
}
Using type Context = ... here would prevent this entirely. Plugins would have no way to extend the type.
Recipe 2 — Discriminated union of typed events
Use a type alias for the union, and individual interfaces (or inline object types) for each variant. The union itself must be a type.
interface UserCreated { type: "user.created"; userId: string; email: string }
interface UserDeleted { type: "user.deleted"; userId: string }
interface UserUpdated { type: "user.updated"; userId: string; changes: Record<string, unknown> }
type UserEvent = UserCreated | UserDeleted | UserUpdated;
function handleEvent(event: UserEvent): void {
switch (event.type) {
case "user.created": console.log(`new user ${event.email}`); break;
case "user.updated": console.log(`updated ${event.userId}`, event.changes); break;
case "user.deleted": console.log(`deleted ${event.userId}`); break;
}
}
Recipe 3 — Repository pattern with implements
A reusable repository interface, two concrete implementations — one in-memory for tests, one against a real database.
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
class InMemoryRepository<T extends { id: string }> implements Repository<T> {
private store = new Map<string, T>();
async findById(id: string): Promise<T | null> { return this.store.get(id) ?? null }
async findAll(): Promise<T[]> { return [...this.store.values()] }
async save(entity: T): Promise<T> { this.store.set(entity.id, entity); return entity }
async delete(id: string): Promise<void> { this.store.delete(id) }
}
interface User { id: string; email: string }
const repo: Repository<User> = new InMemoryRepository<User>();
await repo.save({ id: "u_1", email: "alice@example.com" });
console.log(await repo.findById("u_1"));
Output:
{ id: 'u_1', email: 'alice@example.com' }
Recipe 4 — Utility-type composition with type aliases
type aliases compose naturally with TypeScript's built-in utility types. Building derived shapes from a canonical interface is the most common reason to reach for type.
interface User {
id: string;
email: string;
passwordHash: string;
role: "admin" | "user";
createdAt: Date;
updatedAt: Date;
}
type UserDto = Omit<User, "passwordHash">;
type UserUpdate = Partial<Omit<User, "id" | "createdAt" | "updatedAt">>;
type UserCreate = Omit<User, "id" | "createdAt" | "updatedAt">;
type UserRow = Required<Readonly<User>>;
type AdminUser = User & { role: "admin" };
function publish(user: User): UserDto {
const { passwordHash, ...dto } = user;
return dto;
}
Recipe 5 — Performance refactor (intersection chain to interface chain)
A real-world refactor that drops tsc compile time. Convert deep intersection types into extending interfaces.
// before — repeated intersection re-evaluation
type WithId = { id: string };
type WithTimestamps = { createdAt: Date; updatedAt: Date };
type WithAuthor = { authorId: string };
type WithTags = { tags: string[] };
type BlogPost = WithId & WithTimestamps & WithAuthor & WithTags & {
title: string;
body: string;
};
// after — extends chain, cached by the type-checker
interface WithIdI { id: string }
interface WithTimestampsI { createdAt: Date; updatedAt: Date }
interface WithAuthorI { authorId: string }
interface WithTagsI { tags: string[] }
interface BlogPostI extends WithIdI, WithTimestampsI, WithAuthorI, WithTagsI {
title: string;
body: string;
}
On a 50k-line codebase with hundreds of references, this kind of conversion has been reported to shave 10–20% off cold compile time and dramatically improve editor responsiveness in IDE Intellisense.
Recipe 6 — Hybrid callable interface
A logger that is itself callable (the most-used method) but also has named methods for severity levels.
interface Logger {
(message: string): void;
info(message: string): void;
warn(message: string): void;
error(message: string, err?: unknown): void;
level: "debug" | "info" | "warn" | "error";
}
function makeLogger(level: Logger["level"] = "info"): Logger {
const log = ((msg: string) => console.log(`[${log.level}] ${msg}`)) as Logger;
log.level = level;
log.info = (m) => console.log(`[info] ${m}`);
log.warn = (m) => console.warn(`[warn] ${m}`);
log.error = (m, e) => console.error(`[error] ${m}`, e ?? "");
return log;
}
const log = makeLogger("debug");
log("starting up");
log.info("ready");
log.warn("slow query");
This pattern is the reason jQuery's $() and lodash's _() were typed with interfaces — a function plus a namespace of methods on the same identifier.