TypeScriptInterfaces

Interfaces — Defining Object Shapes

Interfaces are one of TypeScript's most powerful features. They let you define the shape of an object — what properties it must have, what types those properties must be, and what methods it must implement.

Think of an interface as a contract: any object or class that claims to follow the contract must include everything the interface specifies.

Basic Interface Syntax

You define an interface with the interface keyword followed by a name (by convention, PascalCase) and a block describing its members.

JS
interface User {
  id: number;
  name: string;
  email: string;
}

const alice: User = {
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
};

If you try to assign an object that is missing a required property, or has a property of the wrong type, TypeScript reports an error at compile time — before the code ever runs.

JS
// Error: Property 'email' is missing
const bob: User = {
  id: 2,
  name: 'Bob',
};

// Error: Type 'string' is not assignable to type 'number'
const carol: User = {
  id: '3',
  name: 'Carol',
  email: 'carol@example.com',
};
Note
Semicolons inside interface bodies are optional — you can use commas or omit separators entirely. Be consistent within your project.
Optional Properties

Not every property needs to be required. Mark a property as optional by appending ? after its name. TypeScript will allow objects to omit that property, and the property's type becomes T | undefined.

JS
interface UserProfile {
  id: number;
  name: string;
  bio?: string;         // may be present or absent
  avatarUrl?: string;   // same
}

const minimal: UserProfile = { id: 1, name: 'Alice' };            // valid
const full: UserProfile = {                                         // valid
  id: 2,
  name: 'Bob',
  bio: 'Loves TypeScript',
  avatarUrl: 'https://example.com/bob.png',
};

When you access an optional property, TypeScript reminds you it might be undefined:

JS
function displayBio(profile: UserProfile) {
  // TypeScript error — bio could be undefined
  console.log(profile.bio.toUpperCase());

  // Safe approach 1 — optional chaining
  console.log(profile.bio?.toUpperCase());

  // Safe approach 2 — nullish coalescing fallback
  console.log(profile.bio ?? 'No bio provided');

  // Safe approach 3 — explicit check
  if (profile.bio) {
    console.log(profile.bio.toUpperCase()); // TypeScript knows it is string here
  }
}
Tip
Prefer optional chaining (?.) and nullish coalescing (??) over manual undefined checks — they are more concise and compose well.
Readonly Properties

Some properties should only be set once — at creation time — and never mutated afterward. Use the readonly modifier to enforce this.

JS
interface Point {
  readonly x: number;
  readonly y: number;
}

const origin: Point = { x: 0, y: 0 };
origin.x = 10; // Error: Cannot assign to 'x' because it is a read-only property

A practical example — database records often have an immutable primary key:

JS
interface Product {
  readonly id: string;   // set by the database, never changed
  name: string;          // can be updated
  price: number;         // can be updated
  inStock: boolean;
}

function updatePrice(product: Product, newPrice: number): void {
  product.price = newPrice;  // allowed
  product.id = 'new-id';     // Error: Cannot assign to 'id' because it is a read-only property
}
Warning
readonly is a compile-time check only. At runtime the object is a plain JavaScript object and can still be mutated via Object.assign or by casting to any. Use Object.freeze() for true runtime immutability.
Method Signatures

Interfaces can describe the methods an object must expose. There are two equivalent syntaxes — pick one and be consistent.

JS
// Method shorthand syntax (preferred by most style guides)
interface Formatter {
  format(value: number): string;
  formatWithLocale(value: number, locale: string): string;
}

// Property-function syntax
interface FormatterAlt {
  format: (value: number) => string;
  formatWithLocale: (value: number, locale: string) => string;
}

// Both are satisfied by the same implementation
const currencyFormatter: Formatter = {
  format(value) {
    return `${value.toFixed(2)}`;
  },
  formatWithLocale(value, locale) {
    return new Intl.NumberFormat(locale, {
      style: 'currency',
      currency: 'USD',
    }).format(value);
  },
};

Methods can also be optional — useful for plugin-style or lifecycle interfaces:

JS
interface LifecycleHooks {
  onMount(): void;
  onUnmount?(): void;  // optional — not every component needs cleanup
  onError?(error: Error): void;
}
Index Signatures

Sometimes you do not know the exact property names in advance — you just know the key and value types. Index signatures handle this case.

JS
// Any string key maps to a string value
interface StringMap {
  [key: string]: string;
}

const headers: StringMap = {
  'Content-Type': 'application/json',
  'Authorization': 'Bearer token123',
  'X-Custom-Header': 'some-value',
};

You can mix index signatures with known properties — but every known property's type must be assignable to the index signature's value type:

JS
interface Config {
  [key: string]: string | number | boolean;  // catch-all
  appName: string;    // string is assignable to string | number | boolean
  port: number;       // number is assignable
  debug: boolean;     // boolean is assignable
  // version: object; // Error: object is NOT assignable
}

const config: Config = {
  appName: 'MyApp',
  port: 3000,
  debug: true,
  theme: 'dark',      // extra keys are fine — caught by the index signature
};

Number index signatures are useful for array-like structures:

JS
interface ReadonlyList {
  readonly [index: number]: string;
  readonly length: number;
}

// This mirrors how TypeScript's built-in ReadonlyArray works internally
Tip
Prefer specific interfaces over broad index signatures when you know the shape in advance. Index signatures opt you out of property-name typo detection.
Extending Interfaces

Interfaces support inheritance through the extends keyword. A child interface receives all the members of the parent and can add more.

JS
interface Animal {
  name: string;
  age: number;
}

interface Pet extends Animal {
  owner: string;
  vaccinated: boolean;
}

interface ServiceAnimal extends Pet {
  certification: string;
  handler: string;
}

// Must satisfy all three interfaces' requirements
const guide: ServiceAnimal = {
  name: 'Rex',
  age: 4,
  owner: 'John',
  vaccinated: true,
  certification: 'IADP-2023',
  handler: 'Officer Chen',
};

An interface can extend multiple interfaces — something classes cannot do with extends:

JS
interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

interface Validatable {
  validate(): boolean;
  errors: string[];
}

// Combines both contracts into one
interface FormModel extends Serializable, Validatable {
  id: string;
  dirty: boolean;
}

class LoginForm implements FormModel {
  id = 'login';
  dirty = false;
  errors: string[] = [];

  serialize() {
    return JSON.stringify({ id: this.id });
  }
  deserialize(data: string) {
    const parsed = JSON.parse(data);
    this.id = parsed.id;
  }
  validate() {
    this.errors = [];
    // validation logic goes here
    return this.errors.length === 0;
  }
}
Implementing Interfaces with Classes

When a class uses implements, it promises to satisfy the interface contract. TypeScript checks every required member at compile time.

JS
interface Repository<T> {
  findById(id: string): T | undefined;
  findAll(): T[];
  save(entity: T): void;
  delete(id: string): boolean;
}

interface User {
  id: string;
  name: string;
  email: string;
}

class InMemoryUserRepository implements Repository<User> {
  private store: Map<string, User> = new Map();

  findById(id: string): User | undefined {
    return this.store.get(id);
  }

  findAll(): User[] {
    return Array.from(this.store.values());
  }

  save(user: User): void {
    this.store.set(user.id, user);
  }

  delete(id: string): boolean {
    return this.store.delete(id);
  }
}

// Swap for a real database implementation with zero changes to callers
class PostgresUserRepository implements Repository<User> {
  findById(id: string): User | undefined { /* DB query */ return undefined; }
  findAll(): User[] { /* DB query */ return []; }
  save(user: User): void { /* DB insert/update */ }
  delete(id: string): boolean { /* DB delete */ return true; }
}

A class can implement multiple interfaces at once:

JS
interface Loggable {
  log(message: string): void;
}

interface Disposable {
  dispose(): void;
}

class DatabaseConnection implements Loggable, Disposable {
  private connection: unknown = null;

  log(message: string) {
    console.log(`[DB] ${message}`);
  }

  dispose() {
    this.connection = null;
    this.log('Connection closed');
  }
}
Note
Unlike abstract classes, interfaces carry no runtime representation — they are erased during compilation. Use interfaces when you only need to describe a shape; use abstract classes when you also need shared implementation.
Declaration Merging

One capability unique to interfaces (not type aliases) is declaration merging: if you declare the same interface name twice in the same scope, TypeScript merges both declarations into one combined interface.

JS
interface Window {
  appVersion: string;
}

interface Window {
  trackEvent(name: string): void;
}

// TypeScript merges them — Window now has both members.
// This is exactly how @types/* packages augment built-in browser globals.

The most common use case is module augmentation — adding your own properties to third-party types without editing their source:

JS
// src/types/express.d.ts
import 'express';

declare module 'express' {
  interface Request {
    currentUser?: {
      id: string;
      role: 'admin' | 'user';
    };
  }
}

// Now TypeScript knows about req.currentUser in every route handler
// app.get('/profile', (req, res) => {
//   const id = req.currentUser?.id;  // no cast needed
// })
Warning
Do not use declaration merging in application code to work around incorrect types — fix or properly augment the declaration file instead. Merging scattered across many files is hard to track.
Interface vs. Type Alias

Both interface and type can describe object shapes. Here are the key practical differences:

Feature

interface

type

Extending / intersecting

extends keyword

& intersection operator

Declaration merging

Yes — automatic

No — compile error

Union types

Not supported

Yes — type A = B | C

Primitive aliases

Not supported

Yes — type ID = string

Computed / mapped types

Not supported

Yes

Error message clarity

Shows interface name

Can be verbose/inline

Best used for

Objects, classes, APIs

Unions, utilities, primitives

Tip
The TypeScript team recommends interface for public API shapes because error messages are cleaner and declaration merging is available. Use type for unions, intersections, and utility types.
Practical Example: User, Product, and API Response

Here is a realistic set of interfaces you might find in a small e-commerce front-end, showing how interfaces compose into a full data layer.

JS
// ---- Domain entities ----

interface Address {
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface User {
  readonly id: string;
  name: string;
  email: string;
  address?: Address;
  createdAt: Date;
}

interface Category {
  id: string;
  name: string;
  slug: string;
}

interface Product {
  readonly id: string;
  name: string;
  description: string;
  price: number;
  currency: string;
  category: Category;
  imageUrl?: string;
  inStock: boolean;
  tags: string[];
}

// ---- Generic API response wrapper ----

interface ApiError {
  code: string;
  message: string;
  field?: string;   // which field caused a validation error
}

interface PaginationMeta {
  page: number;
  pageSize: number;
  totalItems: number;
  totalPages: number;
}

interface ApiResponse<T> {
  data: T | null;
  errors: ApiError[];
  meta?: PaginationMeta;
  success: boolean;
}

// ---- Concrete response types built from the generic ----

type UserResponse = ApiResponse<User>;
type ProductListResponse = ApiResponse<Product[]>;

// ---- Service contract ----

interface ProductService {
  getById(id: string): Promise<ApiResponse<Product>>;
  list(page: number, pageSize: number): Promise<ProductListResponse>;
  search(query: string): Promise<ProductListResponse>;
  create(payload: Omit<Product, 'id'>): Promise<ApiResponse<Product>>;
  update(id: string, patch: Partial<Product>): Promise<ApiResponse<Product>>;
  delete(id: string): Promise<ApiResponse<void>>;
}

Notice how:

  • readonly id prevents accidental mutation of the server-assigned key
  • address? on User models data that not every user fills in
  • The generic ApiResponse<T> keeps the response wrapper consistent across every endpoint
  • Omit<Product, 'id'> and Partial<Product> (built-in utility types) reuse Product for create and update payloads without duplicating fields

Consuming the service is clean and fully type-safe:

JS
async function loadProduct(
  service: ProductService,
  id: string
): Promise<Product | null> {
  const response = await service.getById(id);

  if (!response.success || !response.data) {
    response.errors.forEach(err => {
      console.error(`[${err.code}] ${err.message}`);
    });
    return null;
  }

  return response.data; // TypeScript narrows to Product here — not null
}
Common Mistakes to Avoid
  • Forgetting that readonly only protects at compile time — use Object.freeze() for runtime safety

  • Mixing index signatures with incompatible named property types — every named property must be assignable to the index signature's value type

  • Over-using declaration merging in application code — reserve it for genuine module augmentation (d.ts files)

  • Naming interfaces with a leading 'I' prefix (IUser, IProduct) — this is a .NET convention and is not idiomatic TypeScript

  • Reaching for an interface when you actually need a union type — use a type alias for unions

  • Implementing an interface in a class and assuming callers can see private fields — callers holding the interface type only see the interface's members

Best Practices
  1. Define one interface per file (or group tightly related ones) to keep diffs small and imports clear

  2. Name interfaces after the concept they model, not their implementation — User, not UserObject or IUser

  3. Use readonly for any property that must not change after construction

  4. Prefer optional properties over union-with-undefined to communicate intent clearly

  5. Compose small, focused interfaces rather than one large interface that covers everything

  6. Use generic interfaces such as Repository<T> and ApiResponse<T> to share structure without duplicating code

  7. Put shared interfaces in a types/ or models/ directory so they are easy to import across the codebase

Success
You now know how to define and compose TypeScript interfaces: required and optional properties, readonly modifiers, method signatures, index signatures, inheritance with extends, class contracts with implements, and declaration merging. These tools let you express precise data shapes that TypeScript enforces throughout your entire codebase.