3.1 Interfaces
In TypeScript, an interface is a contract that defines the structure (shape) of an object. It tells the compiler "this object must have exactly these properties." Interfaces exist only at compile time and are completely erased at runtime, yet they simultaneously serve as the core TypeScript tool for code autocompletion, type checking, and documentation.
This chapter covers everything you will regularly encounter in practice: basic interface syntax, optional and readonly properties, extension via extends, declaration merging, and function and indexable types.
Basic Interface Syntax
Write the interface keyword followed by a name, then list properties and their types inside curly braces.
interface User {
id: number;
name: string;
email: string;
}
Using this interface as a type annotation causes the compiler to check the structure.
const alice: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
};
// Error: property 'email' is missing
const bob: User = {
id: 2,
name: "Bob",
// email omitted → compile error
};
TypeScript uses structural typing, so any object that contains all properties defined in the interface is compatible, even without an explicit declaration.
function greet(user: User): string {
return `Hello, ${user.name}!`;
}
// Passes even without explicitly declaring the interface, as long as the structure matches
const charlie = { id: 3, name: "Charlie", email: "charlie@example.com", role: "admin" };
greet(charlie); // Extra properties are fine (excess property checks only apply when passing an object literal directly)
Optional Properties
Adding ? after a property name makes that property optional — it can be present or absent.
interface UserProfile {
id: number;
name: string;
bio?: string; // optional property
avatarUrl?: string; // optional property
}
const user1: UserProfile = { id: 1, name: "Alice" }; // valid
const user2: UserProfile = { id: 2, name: "Bob", bio: "Developer" }; // valid
An optional property's type becomes string | undefined. Use optional chaining or a type guard when accessing it.
function displayBio(profile: UserProfile): string {
// optional chaining: returns "No bio" if bio is undefined
return profile.bio ?? "No bio";
}
function showAvatar(profile: UserProfile): void {
if (profile.avatarUrl !== undefined) {
console.log(`Avatar: ${profile.avatarUrl}`);
}
}
Readonly Properties
The readonly keyword prevents reassignment after the initial value is set. It is typically used for values that should never change, such as IDs.
interface Point {
readonly x: number;
readonly y: number;
}
const origin: Point = { x: 0, y: 0 };
// origin.x = 10; // Error: cannot assign to a read-only property
interface Config {
readonly apiKey: string;
readonly baseUrl: string;
timeout?: number; // timeout can be changed
}
const config: Config = {
apiKey: "secret-key-123",
baseUrl: "https://api.example.com",
timeout: 5000,
};
config.timeout = 10000; // valid: timeout is not readonly
// config.apiKey = "new"; // Error: apiKey is readonly
readonly provides shallow immutability. It does not protect nested properties inside objects or arrays.
interface AppState {
readonly users: User[];
}
const state: AppState = { users: [] };
// state.users = []; // Error: users itself cannot be reassigned
state.users.push({ id: 1, name: "Alice", email: "a@b.com" }); // valid: the array's contents can still change
Interface Extension (extends)
The extends keyword inherits all properties from another interface, allowing you to build type hierarchies without duplicating code.
Single Extension
interface Animal {
name: string;
age: number;
}
interface Dog extends Animal {
breed: string;
bark(): void;
}
const myDog: Dog = {
name: "Max",
age: 3,
breed: "Golden Retriever",
bark() {
console.log("Woof!");
},
};
Multiple Extension
TypeScript interfaces can extend multiple interfaces at once.
interface Serializable {
serialize(): string;
deserialize(data: string): void;
}
interface Loggable {
log(message: string): void;
logLevel: "info" | "warn" | "error";
}
interface AuditableEntity extends Serializable, Loggable {
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
class UserRecord implements AuditableEntity {
createdAt = new Date();
updatedAt = new Date();
createdBy = "system";
logLevel: "info" | "warn" | "error" = "info";
serialize(): string {
return JSON.stringify(this);
}
deserialize(data: string): void {
Object.assign(this, JSON.parse(data));
}
log(message: string): void {
console.log(`[${this.logLevel.toUpperCase()}] ${message}`);
}
}
When extending, if two interfaces share a property name with conflicting types, a compile error is raised.
interface A { value: string; }
interface B { value: number; }
// interface C extends A, B {} // Error: 'value' conflicts between string and number
Declaration Merging
Declaring an interface with the same name multiple times causes the TypeScript compiler to automatically merge them. This is a unique characteristic of interface that type does not share.
interface Window {
title: string;
}
interface Window {
url: string;
}
// The two declarations are automatically merged
// interface Window { title: string; url: string; }
const win: Window = {
title: "Home",
url: "https://example.com",
};
Merging Function Overloads
When adding function signatures to an interface with the same name, the later declaration is checked first (adding a more specific overload later raises its priority).
interface StringConverter {
convert(value: string): string;
}
interface StringConverter {
convert(value: number): string; // adds an overload
}
// Merged result
// interface StringConverter {
// convert(value: number): string; // later declaration comes first
// convert(value: string): string;
// }
Function Type Interfaces
You can define function types using an interface.
// Define a function type using a call signature
interface Comparator {
(a: number, b: number): number;
}
const ascending: Comparator = (a, b) => a - b;
const descending: Comparator = (a, b) => b - a;
const numbers = [3, 1, 4, 1, 5, 9, 2, 6];
console.log([...numbers].sort(ascending)); // [1, 1, 2, 3, 4, 5, 6, 9]
console.log([...numbers].sort(descending)); // [9, 6, 5, 4, 3, 2, 1, 1]
You can also combine properties with call signatures.
interface HttpClient {
baseUrl: string;
timeout: number;
(url: string, options?: RequestInit): Promise<Response>;
get(url: string): Promise<Response>;
post(url: string, body: unknown): Promise<Response>;
}
Indexable Types
The [key: Type]: ValueType syntax defines the structure of objects with dynamic keys.
String Indexer
interface StringMap {
[key: string]: string;
}
const headers: StringMap = {
"Content-Type": "application/json",
"Authorization": "Bearer token123",
"X-Request-ID": "abc-123",
};
// When an indexer is present, explicit properties must also be compatible with the indexer's value type
interface MixedMap {
[key: string]: string | number;
name: string; // valid: string is included in string | number
count: number; // valid: number is included in string | number
// active: boolean; // Error: boolean is not included in string | number
}
Numeric Indexer
Use a numeric indexer for array-like objects.
interface NumberIndexed {
[index: number]: string;
length: number;
}
const items: NumberIndexed = {
0: "first",
1: "second",
2: "third",
length: 3,
};
Practical Example: User Management System
Design User, Admin, and Guest types as an interface hierarchy.
// Base entity interface
interface BaseEntity {
readonly id: string;
readonly createdAt: Date;
updatedAt: Date;
}
// Basic user interface
interface User extends BaseEntity {
username: string;
email: string;
displayName: string;
avatarUrl?: string;
isActive: boolean;
}
// Permission interface
interface Permission {
resource: string;
actions: ("read" | "write" | "delete" | "admin")[];
}
// Admin interface: User + management privileges
interface Admin extends User {
role: "superadmin" | "moderator";
permissions: Permission[];
managedDepartments: string[];
lastLoginAt?: Date;
canManageUser(targetUserId: string): boolean;
}
// Guest interface: limited access
interface Guest {
readonly sessionId: string;
readonly expiresAt: Date;
allowedPages: string[];
referrerUrl?: string;
}
// System user union (covered in more detail in the next chapter)
type SystemUser = User | Admin | Guest;
// User repository interface
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findAll(filter?: Partial<User>): Promise<User[]>;
create(data: Omit<User, keyof BaseEntity>): Promise<User>;
update(id: string, data: Partial<Omit<User, "id" | "createdAt">>): Promise<User>;
delete(id: string): Promise<boolean>;
}
// Implementation example
class InMemoryUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.email === email) return user;
}
return null;
}
async findAll(filter?: Partial<User>): Promise<User[]> {
const all = Array.from(this.users.values());
if (!filter) return all;
return all.filter((user) =>
Object.entries(filter).every(
([key, value]) => user[key as keyof User] === value
)
);
}
async create(data: Omit<User, keyof BaseEntity>): Promise<User> {
const now = new Date();
const user: User = {
...data,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
};
this.users.set(user.id, user);
return user;
}
async update(
id: string,
data: Partial<Omit<User, "id" | "createdAt">>
): Promise<User> {
const existing = this.users.get(id);
if (!existing) throw new Error(`User not found: ${id}`);
const updated: User = { ...existing, ...data, updatedAt: new Date() };
this.users.set(id, updated);
return updated;
}
async delete(id: string): Promise<boolean> {
return this.users.delete(id);
}
}
// Service layer
interface UserService {
getUser(id: string): Promise<User>;
registerUser(username: string, email: string, displayName: string): Promise<User>;
deactivateUser(id: string): Promise<void>;
promoteToAdmin(userId: string, role: Admin["role"]): Promise<Admin>;
}
Pro Tips: Extending Library Types with Declaration Merging
The most powerful real-world use of declaration merging is extending third-party library types without any runtime changes.
Extending the Express Request Object
// How to add req.user typing when using Express
// Write in src/types/express/index.d.ts
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
role: "user" | "admin";
};
requestId?: string;
startTime?: number;
}
}
}
// Now usable in Express handlers with full type safety
import { Request, Response } from "express";
function authMiddleware(req: Request, res: Response, next: Function): void {
// Access req.user with type safety
if (!req.user) {
res.status(401).json({ error: "Authentication required" });
return;
}
next();
}
Extending the Window Object
// Add custom properties to the browser's global object
// src/types/global.d.ts
declare global {
interface Window {
__APP_CONFIG__: {
apiUrl: string;
version: string;
featureFlags: Record<string, boolean>;
};
__ANALYTICS__?: {
track(event: string, properties?: Record<string, unknown>): void;
};
}
}
// Access with full type safety
const apiUrl = window.__APP_CONFIG__.apiUrl;
window.__ANALYTICS__?.track("page_view", { path: location.pathname });
Extending Environment Variable Types
// Add types to Node.js environment variables
// src/types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production" | "test";
DATABASE_URL: string;
JWT_SECRET: string;
PORT?: string;
REDIS_URL?: string;
}
}
}
// Usage
const port = parseInt(process.env.PORT ?? "3000", 10);
const dbUrl = process.env.DATABASE_URL; // string (not undefined)
A note on merging: only interface supports declaration merging — type alias does not. Also, declare global blocks only work inside module files (files that contain export or import). In a file with no exports at all, you can add an interface declaration directly without declare global and it will be merged globally.
Summary Table
| Feature | Syntax | Description |
|---|---|---|
| Basic declaration | interface Foo { prop: Type } | Defines an object structure |
| Optional | prop?: Type | Property may or may not be present; type is Type | undefined |
| Readonly | readonly prop: Type | Cannot be reassigned after initial assignment |
| Single extension | interface B extends A | Inherits all properties of A |
| Multiple extension | interface C extends A, B | Inherits from both A and B |
| Declaration merging | Duplicate declarations with the same name | Properties are automatically combined |
| Function type | (a: T, b: U): V | Call signature |
| Indexer | [key: string]: T | Objects with dynamic keys |
| Class implementation | class Foo implements Bar | A class implements an interface |
What's Next...
Section 3.2 covers type aliases, where you define types using the type keyword. While type aliases look similar to interfaces, they enable complex type compositions that interfaces cannot express — such as unions (|), intersections (&), and literal types. Understanding the differences between the two tools will help you make the right choice for each situation.