Skip to main content

Interfaces & Type Aliases

TypeScript gives you two ways to describe the shape of an object: interface and type. Both are widely used, both are powerful, and they overlap significantly. Understanding when to use each - and how they differ - is essential for writing idiomatic TypeScript.

Interfaces​

An interface defines the shape of an object. It describes what properties an object must have and what types those properties hold:

interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}

function displayUser(user: User): void {
console.log(`${user.name} <${user.email}> - joined ${user.createdAt.toLocaleDateString()}`);
}

const alice: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
createdAt: new Date("2023-01-15"),
};

displayUser(alice);

TypeScript uses structural typing (more on this below), so any object with the right shape satisfies an interface

  • there is no need to explicitly declare implements User.

Type aliases​

A type alias gives a name to any type expression - not just object shapes:

// Object shape (same as an interface)
type User = {
id: number;
name: string;
email: string;
};

// Union type
type Status = "pending" | "active" | "suspended";

// Primitive alias
type UserId = number;

// Tuple
type Coordinate = [number, number];

// Function type
type Formatter = (value: number) => string;

interface vs type - when to use which​

This is one of the most-asked TypeScript questions. Here is the practical breakdown:

Featureinterfacetype
Describe object shapesYesYes
Union types (A | B)NoYes
Intersection types (A & B)Via extendsYes
Primitive aliasesNoYes
Function typesPossible but verboseConcise
Declaration mergingYesNo
Extends / implements in classesYesYes (via &)
Recursive typesYesYes

General recommendation:

  • Use interface for object shapes that represent domain entities (User, Product, ApiResponse) - especially if other interfaces might extend them.
  • Use type for unions, primitives, tuples, function types, and when you need to compose types with | or &.

In practice, both work for most object definitions. Be consistent within a codebase.

Optional properties​

Mark a property optional with ?. An optional property can be the declared type or undefined:

interface UserProfile {
id: number;
name: string;
bio?: string; // string | undefined
website?: string; // string | undefined
avatarUrl?: string; // string | undefined
}

function renderProfile(profile: UserProfile): string {
const bio = profile.bio ?? "No bio provided";
const website = profile.website ? `<a href="${profile.website}">Website</a>` : "";
return `<h1>${profile.name}</h1><p>${bio}</p>${website}`;
}

// Valid - optional properties can be omitted
const minimalProfile: UserProfile = { id: 1, name: "Alice" };

// Valid - with some optional properties
const fullProfile: UserProfile = {
id: 2,
name: "Bob",
bio: "Software engineer",
website: "https://bob.dev",
};

Readonly properties​

Mark a property readonly to prevent reassignment after object creation:

interface Config {
readonly apiKey: string;
readonly baseUrl: string;
timeout: number; // mutable
}

const config: Config = {
apiKey: "secret-123",
baseUrl: "https://api.example.com",
timeout: 5000,
};

config.timeout = 10000; // OK
// config.apiKey = "other"; // Error: Cannot assign to 'apiKey' because it is a read-only property

readonly is a compile-time check only - the compiled JavaScript has no concept of it. For truly immutable data at runtime, use Object.freeze().

Readonly objects from the outside​

Sometimes you want to expose an object as readonly to consumers while keeping it mutable internally:

interface MutableConfig {
apiKey: string;
timeout: number;
}

type ReadonlyConfig = Readonly<MutableConfig>;

class ConfigManager {
private config: MutableConfig = {
apiKey: "secret",
timeout: 5000,
};

getConfig(): ReadonlyConfig {
return this.config; // Consumers cannot mutate it
}

setTimeout(ms: number): void {
this.config.timeout = ms; // Internal mutation is fine
}
}

Extending interfaces​

Interfaces can extend one or more other interfaces, inheriting all their properties:

interface BaseEntity {
id: number;
createdAt: Date;
updatedAt: Date;
}

interface User extends BaseEntity {
name: string;
email: string;
}

interface AdminUser extends User {
permissions: string[];
lastLogin: Date;
}

// AdminUser has: id, createdAt, updatedAt, name, email, permissions, lastLogin
const admin: AdminUser = {
id: 1,
createdAt: new Date(),
updatedAt: new Date(),
name: "Admin Alice",
email: "admin@example.com",
permissions: ["read", "write", "delete"],
lastLogin: new Date(),
};

Extending multiple interfaces​

interface Serializable {
serialize(): string;
}

interface Loggable {
log(): void;
}

interface Event extends Serializable, Loggable {
type: string;
timestamp: Date;
payload: unknown;
}

Declaration merging​

Interfaces support declaration merging - if you declare the same interface name twice, TypeScript merges the declarations:

interface Window {
myCustomProperty: string;
}

// Later in another file:
interface Window {
anotherProperty: number;
}

// TypeScript merges them: Window has both myCustomProperty and anotherProperty

This is useful for augmenting existing type definitions, especially in library code or when extending browser globals. type aliases do not support merging - redeclaring a type alias is an error.

Intersection types​

Intersection types combine multiple types into one using &. The result must satisfy all combined types:

interface HasName {
name: string;
}

interface HasAge {
age: number;
}

type Person = HasName & HasAge;

const person: Person = {
name: "Alice",
age: 30,
};

Intersection is often used to mix in functionality:

interface Timestamped {
createdAt: Date;
updatedAt: Date;
}

interface SoftDeletable {
deletedAt: Date | null;
}

type AuditedUser = User & Timestamped & SoftDeletable;

Intersection vs extension​

Interfaces use extends to inherit; type aliases use & to intersect. The results are similar but not identical:

// With interface
interface Employee extends User {
department: string;
}

// With type (equivalent result)
type Employee = User & { department: string };

The key difference: when properties conflict, extends may produce an error (if types are incompatible), while & may produce never for the conflicting property.

Structural typing​

TypeScript uses structural typing (also called "duck typing"): compatibility is determined by the shape of a type, not its name or declaration.

interface Point2D {
x: number;
y: number;
}

interface Coordinate {
x: number;
y: number;
}

function distance(a: Point2D, b: Point2D): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}

const coord: Coordinate = { x: 3, y: 4 };
distance({ x: 0, y: 0 }, coord); // OK! coord satisfies Point2D structurally

This is different from nominal typing (Java, C#) where Coordinate and Point2D would be incompatible types even with identical shapes. TypeScript is happy as long as the required properties are present.

Excess property checks​

There is one exception: when you pass an object literal directly to a typed variable, TypeScript performs excess property checking and rejects unknown properties:

interface Options {
timeout: number;
retries: number;
}

function fetchData(url: string, options: Options): void { /* ... */ }

// Error: Object literal may only specify known properties,
// and 'debug' does not exist in type 'Options'
fetchData("/api", { timeout: 5000, retries: 3, debug: true });

// But this works (the object is first assigned to a variable):
const opts = { timeout: 5000, retries: 3, debug: true };
fetchData("/api", opts); // OK (structural compatibility, no excess property check)

Index signatures​

When you do not know the property names ahead of time, use an index signature:

interface StringMap {
[key: string]: string;
}

const headers: StringMap = {
"Content-Type": "application/json",
"Authorization": "Bearer abc123",
"X-Request-ID": "req-456",
};

// You can add any string key:
headers["Accept"] = "application/json";

Index signatures are useful for dictionaries, but make the interface less precise. Prefer explicit properties when you know them:

interface HttpHeaders {
"Content-Type"?: string;
"Authorization"?: string;
"Accept"?: string;
[key: string]: string | undefined; // still allows arbitrary headers
}

Callable interfaces and function types​

Interfaces can describe functions:

interface Validator {
(value: string): boolean;
}

const isEmail: Validator = (value) => /^[^@]+@[^@]+\.[^@]+$/.test(value);
const isUrl: Validator = (value) => value.startsWith("https://");

// type alias is cleaner for this use case:
type ValidatorFn = (value: string) => boolean;

Putting it together: a domain model​

Here is a realistic domain model for an e-commerce application:

interface Money {
amount: number;
currency: "USD" | "EUR" | "GBP";
}

interface BaseEntity {
readonly id: string;
readonly createdAt: Date;
updatedAt: Date;
}

interface Product extends BaseEntity {
name: string;
description: string;
price: Money;
sku: string;
stock: number;
categories: string[];
imageUrl?: string;
}

interface CartItem {
product: Product;
quantity: number;
}

interface Cart {
readonly id: string;
items: CartItem[];
readonly userId: string;
}

type CartSummary = {
itemCount: number;
total: Money;
items: Array<{ name: string; quantity: number; subtotal: Money }>;
};

function calculateTotal(cart: Cart): CartSummary {
const items = cart.items.map((item) => ({
name: item.product.name,
quantity: item.quantity,
subtotal: {
amount: item.product.price.amount * item.quantity,
currency: item.product.price.currency,
},
}));

const total = items.reduce((sum, item) => sum + item.subtotal.amount, 0);
const currency = cart.items[0]?.product.price.currency ?? "USD";

return {
itemCount: cart.items.reduce((n, item) => n + item.quantity, 0),
total: { amount: total, currency },
items,
};
}

Summary​

  • Interfaces define object shapes and support extends, declaration merging, and implements
  • Type aliases are more flexible - they can name unions, intersections, tuples, and primitives
  • Use interface for domain objects; use type for unions, functions, and compositions
  • Optional properties (?) allow a property to be omitted or undefined
  • Readonly properties prevent reassignment after object creation
  • Intersection types (&) combine multiple types into one
  • TypeScript uses structural typing - compatibility is based on shape, not name
  • Excess property checks apply only to object literals passed directly to typed variables
  • Index signatures allow arbitrary string keys when property names are not known in advance

Next up: Functions - typed parameters, return types, optional and default parameters, rest parameters, function overloads, and arrow functions.