Interfaces in TypeScript
Define object shapes with interfaces, extend and merge them, use readonly and optional properties, and understand when to use interface vs type.
What Is an Interface?
In TypeScript, an interface is a way to name and describe the shape of an object. It tells the compiler: “anything I call a User must have these exact properties with these exact types.” This is valuable because it moves errors from runtime to compile time — instead of discovering that a property is missing when your app crashes in production, TypeScript flags it the moment you write the code.
Interfaces also act as documentation. When a function declares it accepts a User, anyone reading the code immediately knows what properties they can expect, without needing to trace through the rest of the codebase.
interface User {
id: number;
name: string;
email: string;
}
function displayUser(user: User): string {
return `${user.name} (${user.email})`;
}
const alice: User = { id: 1, name: "Alice", email: "[email protected]" };
displayUser(alice); // fine
// Missing a required property — caught at compile time, not at runtime
const bob: User = { id: 2, name: "Bob" };
// Error: Property 'email' is missing in type '{ id: number; name: string; }'
Optional Properties
Not every property of an object is always present. A product might not have a description. A user profile might not have an avatar URL. Optional properties let you model this reality without splitting into multiple interfaces or using any.
When you mark a property with ?, TypeScript tracks that it might be undefined and forces you to handle that case before using the value. This prevents the classic “Cannot read properties of undefined” runtime error.
interface Product {
id: number;
name: string;
description?: string; // present on some products, absent on others
price: number;
}
const item: Product = { id: 1, name: "Widget", price: 9.99 }; // valid — description omitted
function showDescription(product: Product): string {
// TypeScript forces you to handle the missing case
return product.description ?? "No description available";
}
// Without the ?? check, TypeScript would warn:
// 'product.description' is possibly 'undefined'
Readonly Properties
Some properties should be set once and never changed. A database record’s id, a point’s coordinates, an API response’s createdAt timestamp — these are values that mutating would be a bug, not a feature.
readonly enforces this at the type level. The value can be set during object creation, but any subsequent assignment is a compile-time error. This catches a whole class of accidental mutation bugs.
interface Point {
readonly x: number;
readonly y: number;
}
const origin: Point = { x: 0, y: 0 };
origin.x = 5; // Error: Cannot assign to 'x' because it is a read-only property
// To "move" a point, you create a new one — you don't mutate the original
const moved: Point = { x: origin.x + 5, y: origin.y };
readonly is especially useful for configuration objects and value types that should be treated as immutable throughout your application.
Methods in Interfaces
Interfaces aren’t limited to data properties — they can also define the methods an object must expose. This is how you define contracts for services, repositories, and any object that needs to guarantee certain behavior.
interface Repository<T> {
findById(id: number): T | null;
findAll(): T[];
save(entity: T): T;
delete(id: number): void;
}
// Any class implementing this must provide all four methods
interface UserRepository extends Repository<User> {
findByEmail(email: string): User | null; // additional user-specific query
}
There are two syntaxes for method signatures, and the difference matters with strict TypeScript settings:
interface Logger {
log(message: string): void; // method syntax — bivariant (looser)
warn: (message: string) => void; // property syntax — contravariant (stricter)
}
Prefer the property syntax (warn: (message: string) => void) for stricter type safety — it prevents unsound subtype assignments that the method syntax silently allows.
Extending Interfaces
As your application grows, you’ll have objects that share a common base structure but add their own fields. Extending interfaces lets you build on existing contracts rather than duplicating them, which keeps your types consistent and easy to update.
If you change a field in the base interface, all extending interfaces automatically inherit the change. No copy-paste drift.
interface Entity {
id: number;
createdAt: Date;
updatedAt: Date;
}
interface User extends Entity {
name: string;
email: string;
}
interface AdminUser extends User {
permissions: string[];
lastLogin: Date;
}
// AdminUser now has: id, createdAt, updatedAt, name, email, permissions, lastLogin
An interface can extend multiple parents at once, which lets you compose capabilities from separate contracts:
interface Serializable {
serialize(): string;
}
interface Validatable {
validate(): boolean;
}
interface Model extends Serializable, Validatable {
id: number;
}
// Any Model must implement serialize(), validate(), and have an id
Index Signatures
Sometimes you don’t know all the keys an object will have upfront — think HTTP headers, query parameters, or a dictionary of settings. Index signatures let you type these dynamic-key objects while still getting type checking on the values.
interface StringMap {
[key: string]: string;
}
const headers: StringMap = {
"Content-Type": "application/json",
"Authorization": "Bearer token",
};
headers["X-Custom"] = "value"; // fine — string value
headers["count"] = 42; // Error: number is not assignable to string
You can mix specific known properties with an index signature. The catch is that specific property types must be compatible with the index signature’s value type — that’s why unknown is often the right choice:
interface Config {
debug: boolean; // a known, specific property
[key: string]: unknown; // all other keys can be anything
}
Declaration Merging
One of the unique things about interface (compared to type) is that you can declare the same interface multiple times and TypeScript merges them. This is especially useful for augmenting types from third-party libraries that you don’t own.
For example, adding a user property to Express’s Request type so every route handler knows about it:
// src/types/express.d.ts
import { User } from "../models/User";
declare global {
namespace Express {
interface Request {
user?: User; // merged into Express's existing Request interface
}
}
}
// Now in any route handler:
app.get("/profile", (req, res) => {
console.log(req.user?.name); // TypeScript knows this exists
});
Without declaration merging, you’d have to cast req to a custom type everywhere, which is noisy and error-prone.
interface vs type
Both interface and type can describe object shapes, and in most cases they’re interchangeable. The differences become important at the edges:
// interface — open, can be extended and merged
interface Point {
x: number;
y: number;
}
// type alias — closed, more flexible for non-object types
type Point = {
x: number;
y: number;
};
Use interface when:
- Describing object shapes and class contracts
- You need declaration merging (augmenting third-party types)
- You want
implementschecking in classes - Building a hierarchy with
extends
Use type when:
- Creating union types:
type Status = "active" | "inactive" - Creating intersection types:
type AdminUser = User & Admin - Using mapped/conditional types:
type Partial<T> = { [K in keyof T]?: T[K] } - Defining tuples:
type Coordinates = [number, number]
A practical rule: use interface for object shapes (especially public API contracts), and type for everything else. Pick one convention and stick to it across your codebase.
Implementing an Interface in a Class
When a class declares implements SomeInterface, TypeScript verifies at compile time that the class provides all the required properties and methods. This is the foundation of coding to abstractions — your functions depend on the interface, not a specific class, which makes swapping implementations (e.g., in tests) trivial.
interface Animal {
name: string;
speak(): string;
}
class Dog implements Animal {
name: string;
constructor(name: string) {
this.name = name;
}
speak(): string {
return "Woof!";
}
}
class Cat implements Animal {
name = "Cat";
speak() {
return "Meow!";
}
}
// A function that works with ANY Animal — Dog, Cat, or anything else
function makeNoise(animal: Animal): void {
console.log(`${animal.name} says: ${animal.speak()}`);
}
makeNoise(new Dog("Rex")); // Rex says: Woof!
makeNoise(new Cat()); // Cat says: Meow!
A class can implement multiple interfaces simultaneously, which is how you compose capabilities without deep inheritance chains:
class WorkerService implements Runnable, Stoppable, Serializable {
// must satisfy all three interfaces
}
Practical Example: A Typed API Client
Interfaces shine when defining the contract for a service layer. Here every method’s inputs and outputs are explicit — the compiler verifies call sites, auto-complete works everywhere, and the interface serves as living documentation.
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface PaginatedResponse<T> extends ApiResponse<T[]> {
page: number;
totalPages: number;
totalCount: number;
}
interface UserService {
getUser(id: number): Promise<ApiResponse<User>>;
listUsers(page: number): Promise<PaginatedResponse<User>>;
createUser(data: Omit<User, "id">): Promise<ApiResponse<User>>;
updateUser(id: number, data: Partial<User>): Promise<ApiResponse<User>>;
deleteUser(id: number): Promise<ApiResponse<void>>;
}
// A real HTTP implementation
class HttpUserService implements UserService {
constructor(private baseUrl: string) {}
async getUser(id: number): Promise<ApiResponse<User>> {
const res = await fetch(`${this.baseUrl}/users/${id}`);
return res.json();
}
// ... other methods
}
// A mock for tests — same interface, no HTTP calls
class MockUserService implements UserService {
private users: User[] = [{ id: 1, name: "Alice", email: "[email protected]" }];
async getUser(id: number): Promise<ApiResponse<User>> {
const user = this.users.find(u => u.id === id) ?? null;
return { data: user!, status: user ? 200 : 404, message: user ? "ok" : "not found" };
}
// ... other methods
}
Both implementations are interchangeable wherever UserService is expected. This is the core value of interfaces: the code that uses a service doesn’t need to change when the implementation does.