TypeScript — Interfaces
Interfaces describe the shape of objects — their properties, methods, and structure. They are the primary way to define contracts in TypeScript and are central to object-oriented patterns. Interfaces are extendable, mergeable, and tree-shakeable.
| 1 | // Basic interface |
| 2 | interface User { |
| 3 | name: string; |
| 4 | age: number; |
| 5 | email: string; |
| 6 | } |
| 7 | |
| 8 | // Interface with methods |
| 9 | interface Greeter { |
| 10 | greet(message: string): string; |
| 11 | farewell(): string; |
| 12 | } |
| 13 | |
| 14 | // Interface with both properties and methods |
| 15 | interface Repository<T> { |
| 16 | findAll(): T[]; |
| 17 | findById(id: string): T | undefined; |
| 18 | save(item: T): void; |
| 19 | delete(id: string): boolean; |
| 20 | } |
| 21 | |
| 22 | // Implementing an interface |
| 23 | class UserRepository implements Repository<User> { |
| 24 | private users: User[] = []; |
| 25 | |
| 26 | findAll(): User[] { |
| 27 | return this.users; |
| 28 | } |
| 29 | |
| 30 | findById(id: string): User | undefined { |
| 31 | return this.users.find((u) => u.name === id); |
| 32 | } |
| 33 | |
| 34 | save(user: User): void { |
| 35 | this.users.push(user); |
| 36 | } |
| 37 | |
| 38 | delete(name: string): boolean { |
| 39 | const idx = this.users.findIndex((u) => u.name === name); |
| 40 | if (idx !== -1) { |
| 41 | this.users.splice(idx, 1); |
| 42 | return true; |
| 43 | } |
| 44 | return false; |
| 45 | } |
| 46 | } |
| 1 | // Optional properties — suffix with ? |
| 2 | interface Config { |
| 3 | host: string; |
| 4 | port: number; |
| 5 | debug?: boolean; // optional |
| 6 | ssl?: { cert: string; key: string }; // optional nested |
| 7 | } |
| 8 | |
| 9 | const config: Config = { host: "localhost", port: 3000 }; // OK |
| 10 | const debugConfig: Config = { |
| 11 | host: "localhost", |
| 12 | port: 3000, |
| 13 | debug: true, |
| 14 | }; |
| 15 | |
| 16 | // Readonly properties — cannot be reassigned after creation |
| 17 | interface Coordinates { |
| 18 | readonly x: number; |
| 19 | readonly y: number; |
| 20 | } |
| 21 | |
| 22 | const point: Coordinates = { x: 1, y: 2 }; |
| 23 | // point.x = 5; // Error: Cannot assign to 'x' because it is a read-only property |
| 24 | |
| 25 | // ReadonlyArray — readonly tuple-like interface |
| 26 | interface Path { |
| 27 | readonly points: ReadonlyArray<Coordinates>; |
| 28 | } |
| 29 | |
| 30 | // Readonly vs mutable |
| 31 | function processPoint(p: Coordinates) { |
| 32 | console.log(p.x, p.y); |
| 33 | // p.x = 0; // Error |
| 34 | } |
| 35 | |
| 36 | // Deep readonly — use utility types for full immutability |
| 37 | interface AppState { |
| 38 | user: { |
| 39 | name: string; |
| 40 | preferences: { |
| 41 | theme: "light" | "dark"; |
| 42 | language: string; |
| 43 | }; |
| 44 | }; |
| 45 | } |
| 46 | type DeepReadonly<T> = { |
| 47 | readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; |
| 48 | }; |
info
Index signatures allow interfaces to describe properties that are not known at definition time — like dictionaries or maps with dynamic keys.
| 1 | // String index signature |
| 2 | interface StringMap { |
| 3 | [key: string]: string; |
| 4 | } |
| 5 | |
| 6 | const headers: StringMap = { |
| 7 | "Content-Type": "application/json", |
| 8 | Authorization: "Bearer token123", |
| 9 | }; |
| 10 | |
| 11 | // Numeric index signature |
| 12 | interface NumberGrid { |
| 13 | [row: number]: number[]; |
| 14 | } |
| 15 | |
| 16 | const grid: NumberGrid = { |
| 17 | 0: [1, 2, 3], |
| 18 | 1: [4, 5, 6], |
| 19 | }; |
| 20 | |
| 21 | // Index signature with named properties |
| 22 | interface Dictionary { |
| 23 | [key: string]: string; |
| 24 | length: number; // must be compatible with string index |
| 25 | } |
| 26 | |
| 27 | // Record<string, T> is the idiomatic shorthand |
| 28 | const scores: Record<string, number> = { |
| 29 | alice: 95, |
| 30 | bob: 87, |
| 31 | }; |
| 32 | |
| 33 | // Readonly index signature |
| 34 | interface FrozenMap { |
| 35 | readonly [key: string]: number; |
| 36 | } |
| 37 | |
| 38 | // Map with both string and number index |
| 39 | interface Mixed { |
| 40 | [key: string]: string | number; |
| 41 | [key: number]: boolean; |
| 42 | } |
| 43 | |
| 44 | // Using index signatures for configuration |
| 45 | interface Env { |
| 46 | [key: `VITE_${string}`]: string; |
| 47 | } |
| 48 | |
| 49 | declare const env: Env; |
| 50 | const apiUrl = env.VITE_API_URL; // string |
| 51 | // env.DATABASE_URL; // Error: not in pattern |
warning
| 1 | // Call signature — interface describes a callable function |
| 2 | interface SearchFunc { |
| 3 | (source: string, query: string): boolean; |
| 4 | } |
| 5 | |
| 6 | const search: SearchFunc = (source, query) => { |
| 7 | return source.includes(query); |
| 8 | }; |
| 9 | search("hello world", "hello"); // true |
| 10 | |
| 11 | // Call signature with properties |
| 12 | interface Logger { |
| 13 | (message: string): void; |
| 14 | level: string; |
| 15 | setLevel(level: string): void; |
| 16 | } |
| 17 | |
| 18 | function createLogger(level: string): Logger { |
| 19 | const log = ((msg: string) => console.log(`[${level}] ${msg}`)) as Logger; |
| 20 | log.level = level; |
| 21 | log.setLevel = (newLevel: string) => { |
| 22 | log.level = newLevel; |
| 23 | }; |
| 24 | return log; |
| 25 | } |
| 26 | |
| 27 | // Construct signature — describes a class constructor |
| 28 | interface ClockConstructor { |
| 29 | new (hour: number, minute: number): ClockInterface; |
| 30 | } |
| 31 | |
| 32 | interface ClockInterface { |
| 33 | tick(): void; |
| 34 | } |
| 35 | |
| 36 | function createClock( |
| 37 | ctor: ClockConstructor, |
| 38 | hour: number, |
| 39 | minute: number |
| 40 | ): ClockInterface { |
| 41 | return new ctor(hour, minute); |
| 42 | } |
| 43 | |
| 44 | class DigitalClock implements ClockInterface { |
| 45 | constructor(private h: number, private m: number) {} |
| 46 | tick(): void { |
| 47 | console.log(`beep beep ${this.h}:${this.m}`); |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | class AnalogClock implements ClockInterface { |
| 52 | constructor(private h: number, private m: number) {} |
| 53 | tick(): void { |
| 54 | console.log(`tick tock ${this.h}:${this.m}`); |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | const digital = createClock(DigitalClock, 12, 0); |
| 59 | const analog = createClock(AnalogClock, 12, 0); |
| 60 | digital.tick(); // beep beep 12:0 |
| 61 | analog.tick(); // tick tock 12:0 |
| 1 | // Basic extension |
| 2 | interface Animal { |
| 3 | name: string; |
| 4 | weight: number; |
| 5 | } |
| 6 | |
| 7 | interface Dog extends Animal { |
| 8 | breed: string; |
| 9 | bark(): void; |
| 10 | } |
| 11 | |
| 12 | const rex: Dog = { |
| 13 | name: "Rex", |
| 14 | weight: 30, |
| 15 | breed: "German Shepherd", |
| 16 | bark: () => console.log("Woof!"), |
| 17 | }; |
| 18 | |
| 19 | // Multiple extension (like mixins) |
| 20 | interface Printable { |
| 21 | print(): string; |
| 22 | } |
| 23 | |
| 24 | interface Loggable { |
| 25 | log(message: string): void; |
| 26 | } |
| 27 | |
| 28 | interface Auditable extends Printable, Loggable { |
| 29 | auditId: number; |
| 30 | auditDate: Date; |
| 31 | } |
| 32 | |
| 33 | // Extending with overrides (narrowing allowed) |
| 34 | interface Base { |
| 35 | value: string | number; |
| 36 | } |
| 37 | |
| 38 | interface Narrowed extends Base { |
| 39 | value: string; // narrows the type — OK |
| 40 | } |
| 41 | |
| 42 | // Chain of extensions |
| 43 | interface HasId { |
| 44 | id: string; |
| 45 | } |
| 46 | |
| 47 | interface HasTimestamps { |
| 48 | createdAt: Date; |
| 49 | updatedAt: Date; |
| 50 | } |
| 51 | |
| 52 | interface HasMetadata { |
| 53 | tags: string[]; |
| 54 | } |
| 55 | |
| 56 | interface BaseEntity extends HasId, HasTimestamps, HasMetadata { |
| 57 | name: string; |
| 58 | } |
| 59 | |
| 60 | interface User extends BaseEntity { |
| 61 | email: string; |
| 62 | role: "admin" | "user" | "viewer"; |
| 63 | } |
| 64 | |
| 65 | // User now has: id, createdAt, updatedAt, tags, name, email, role |
When you declare the same interface multiple times, TypeScript merges them automatically. This is the foundation of module augmentation and declaration merging.
| 1 | // Simple merging — properties from both declarations combine |
| 2 | interface Box { |
| 3 | width: number; |
| 4 | } |
| 5 | |
| 6 | interface Box { |
| 7 | height: number; |
| 8 | } |
| 9 | |
| 10 | // Box is now { width: number; height: number } |
| 11 | const box: Box = { width: 10, height: 20 }; |
| 12 | |
| 13 | // Merging with methods |
| 14 | interface Counter { |
| 15 | count: number; |
| 16 | } |
| 17 | |
| 18 | interface Counter { |
| 19 | increment(): void; |
| 20 | decrement(): void; |
| 21 | reset(): void; |
| 22 | } |
| 23 | |
| 24 | // Counter now has count + increment + decrement + reset |
| 25 | |
| 26 | // Module augmentation — extend existing modules |
| 27 | // In typescript declaration files (.d.ts) |
| 28 | declare module "express" { |
| 29 | interface Request { |
| 30 | userId?: string; |
| 31 | sessionId?: string; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | // After augmentation, req.userId is available in Express handlers |
| 36 | // app.get("/", (req) => { req.userId; }) — type-safe |
| 37 | |
| 38 | // Namespace merging |
| 39 | interface Cloner { |
| 40 | clone(): HTMLElement; |
| 41 | } |
| 42 | |
| 43 | interface Cloner { |
| 44 | clone(deep: boolean): HTMLElement; |
| 45 | } |
| 46 | |
| 47 | // Cloner now has two clone signatures (like overloads) |
| 48 | |
| 49 | // Merge rules: |
| 50 | // 1. Properties with the same name must have the same type |
| 51 | // 2. Methods with the same name merge (become overloads) |
| 52 | // 3. Non-function members must be unique (or identical) |
| 53 | // 4. The order of declarations doesn't matter |
best practice
| 1 | // Interface — open for extension (declaration merging) |
| 2 | interface User { |
| 3 | name: string; |
| 4 | } |
| 5 | interface User { |
| 6 | age: number; |
| 7 | } |
| 8 | // User = { name: string; age: number } — merged |
| 9 | |
| 10 | // Type alias — closed, cannot re-declare |
| 11 | type UserAlias = { name: string }; |
| 12 | // type UserAlias = { age: number }; // Error: Duplicate identifier |
| 13 | |
| 14 | // Interface extends — class-like syntax |
| 15 | interface Animal { |
| 16 | name: string; |
| 17 | } |
| 18 | interface Dog extends Animal { |
| 19 | breed: string; |
| 20 | } |
| 21 | |
| 22 | // Type alias intersection — more flexible composition |
| 23 | type AnimalAlias = { name: string }; |
| 24 | type DogAlias = AnimalAlias & { breed: string }; |
| 25 | |
| 26 | // When to use which: |
| 27 | // Interface: object shapes, class contracts, public APIs |
| 28 | // Type alias: unions, intersections, mapped types, primitives, tuples |
| 29 | |
| 30 | // Interface can be used with extends (class-like) |
| 31 | interface Point { |
| 32 | x: number; |
| 33 | y: number; |
| 34 | } |
| 35 | interface Point3D extends Point { |
| 36 | z: number; |
| 37 | } |
| 38 | |
| 39 | // Type alias can be a union (interfaces cannot) |
| 40 | type Result = { ok: true; data: string } | { ok: false; error: string }; |
| 41 | |
| 42 | // Type alias for primitives |
| 43 | type ID = string | number; |
| 44 | type UserID = string & { __brand: "UserID" }; // branded type |
| 45 | |
| 46 | // Both work for function types |
| 47 | type Formatter = (input: string) => string; |
| 48 | interface FormatterInterface { |
| 49 | (input: string): string; |
| 50 | } |
| 51 | |
| 52 | // Both work for arrays |
| 53 | type StringArray = string[]; |
| 54 | interface StringArrayInterface extends Array<string> {} |
| 55 | |
| 56 | // Performance: interfaces may be faster for type checking in large codebases |
| 57 | // because they can be compared by reference (declaration merging creates |
| 58 | // a single cached type). Type aliases may create new types each time. |
info
1. Use interfaces for public API contracts — they are extensible via declaration merging and provide better error messages.
2. Prefer ReadonlyArray<T> and readonly modifiers for data that should not be mutated.
3. Avoid index signatures when you know the exact keys — named properties provide better autocompletion and type safety.
4. Use generic interfaces like Repository<T> or ApiResponse<T> for reusable type-safe patterns.
5. Use declaration merging to augment third-party types in .d.ts files — never modify the library source directly.
6. Prefer extension (extends) over intersection (&) for combining object types — it produces cleaner error messages.
7. Name interfaces after the contract they describe: UserService, CacheProvider, EventEmitter.
8. Avoid any in interface properties — use unknown if you need flexibility and narrow at usage sites.