TypeScript Interfaces

An interface is TypeScript’s primary tool for describing the shape of an object: which properties it must have, what type each property is, and what methods it exposes. Interfaces exist only at compile time — they add zero bytes to your compiled JavaScript — but they let the compiler catch typos, missing fields, and type mismatches before your code ever runs. If you’ve written object literals, function parameters, or class-based code in plain JavaScript, an interface is how you put a contract around that shape so the compiler (and your editor) can hold you to it.

Overview / How It Works

TypeScript uses structural typing (sometimes called “duck typing”): a value satisfies an interface if it has the right shape, regardless of how it was created or what it’s named. There is no implements requirement for plain objects — if an object has all the required properties with compatible types, it matches the interface. This is fundamentally different from nominal type systems (like Java or C#), where a class must explicitly declare that it implements an interface to be considered compatible.

An interface declaration itself produces no runtime code. When the TypeScript compiler emits JavaScript, every interface block is erased entirely — you can’t console.log an interface, use instanceof against it, or find any trace of it in the compiled output. It exists purely to let the type checker verify your code at compile time.

Interfaces can describe more than plain data objects. They can describe function types, class instance shapes (via the implements keyword), objects with dynamic keys (index signatures), and they can extend one another to build up more specific shapes from general ones. Two interfaces declared with the same name in the same scope are automatically merged into one — a feature called declaration merging that’s unique to interfaces (type aliases cannot do this).

Interfaces vs. Type Aliases

TypeScript also lets you describe shapes with type. The two overlap a lot, but they aren’t identical:

Feature interface type
Describe object shapes Yes Yes
Extend / combine extends & (intersection)
Declaration merging Yes No
Union types No Yes
Primitives, tuples, mapped types No Yes

A common convention: use interface for object and class shapes that might need to be extended or merged, and type for unions, tuples, and anything that isn’t a plain object shape.

Syntax

interface Name {
  requiredProp: Type;
  optionalProp?: Type;
  readonly immutableProp: Type;
  methodName(param: Type): ReturnType;
  [dynamicKey: string]: Type; // index signature
}
  • interface Name { … } — declares a new named type; by convention, use PascalCase.
  • requiredProp: Type — every object claiming this interface must have this property with a compatible type.
  • optionalProp?: Type — the ? marks the property as optional; its type is really Type | undefined.
  • readonly immutableProp: Type — can be read but not reassigned after the object is created.
  • methodName(param: Type): ReturnType — a method signature; equivalent to a property whose type is a function.
  • [dynamicKey: string]: Type — an index signature, allowing any number of string keys as long as their values match Type.

Examples

Example 1: A Basic Object Shape

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

function printUser(user: User): void {
  console.log(`${user.id}: ${user.name}`);
  if (user.email) {
    console.log(`Email: ${user.email}`);
  }
}

const user: User = {
  id: 1,
  name: "Ava",
  createdAt: new Date("2024-01-01"),
};

printUser(user);

Output:

1: Ava

The User interface requires id, name, and createdAt, but email is optional, so omitting it is fine. createdAt is readonly, meaning once user is created, code like user.createdAt = new Date() would fail to compile.

Example 2: Extending Interfaces and Method Signatures

interface Shape {
  color: string;
  area(): number;
}

interface Circle extends Shape {
  radius: number;
}

const circle: Circle = {
  color: "red",
  radius: 4,
  area() {
    return Math.PI * this.radius ** 2;
  },
};

console.log(circle.color, circle.area().toFixed(2));

Output:

red 50.27

Circle extends Shape means every Circle must satisfy everything Shape requires, plus its own radius property. This mirrors class inheritance but works purely on object shape — circle is a plain object literal, not an instance of any class.

Example 3: A Realistic Repository Pattern

interface Logger {
  (message: string): void;
}

interface Repository {
  getById(id: number): T | undefined;
  getAll(): T[];
}

interface Product {
  id: number;
  name: string;
  price: number;
}

class ProductRepository implements Repository {
  private items: Product[] = [
    { id: 1, name: "Keyboard", price: 49.99 },
    { id: 2, name: "Mouse", price: 19.99 },
  ];

  getById(id: number): Product | undefined {
    return this.items.find((item) => item.id === id);
  }

  getAll(): Product[] {
    return this.items;
  }
}

const log: Logger = (message) => console.log(`[LOG] ${message}`);

const repo = new ProductRepository();
log(`Found ${repo.getAll().length} products`);

const product = repo.getById(1);
if (product) {
  log(`Product 1: ${product.name} ($${product.price})`);
}

Output:

[LOG] Found 2 products
[LOG] Product 1: Keyboard ($49.99)

This combines three interface features at once: Logger describes a callable function type, Repository<T> is a generic interface that a class formally implements with the implements keyword, and Product is the plain data shape flowing through it. Notice that a class using implements Repository<Product> is checked by the compiler at declaration time — if ProductRepository were missing getAll, TypeScript would refuse to compile it.

How It Works Step by Step / Under the Hood

When the compiler checks const user: User = {...}, it doesn’t check that the object was constructed “as a User” — there’s no such runtime tag. Instead, for every property required by User, it verifies the object literal has a compatible property. This is why two completely unrelated interfaces with identical members are freely interchangeable — TypeScript compares shapes, not names.

Interfaces also support declaration merging: if you declare the same interface name twice, TypeScript combines their members into a single interface rather than raising a duplicate-identifier error. This is how libraries like Express or the DOM lib let you “add” properties to existing interfaces such as Window.

interface Config {
  apiUrl: string;
}

interface Config {
  timeout: number;
}

const config: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

console.log(config.apiUrl, config.timeout);

Output:

https://api.example.com 5000

Once compiled to JavaScript, all of this — the interface keywords, the property type annotations, the generic parameter <T> — disappears completely. Only the object literals, the class body, and the function calls remain. This is type erasure: TypeScript’s type system is a compile-time-only layer bolted on top of JavaScript, never a runtime feature.

Common Mistakes

Mistake 1: Fighting the Excess Property Check

TypeScript performs a stricter check — called the excess property check — specifically when you assign an object literal directly, which trips people up:

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

function logPoint(point: Point): void {
  console.log(point.x, point.y);
}

logPoint({ x: 1, y: 2, z: 3 });

This fails with something like: Argument of type ‘{ x: number; y: number; z: number; }’ is not assignable to parameter of type ‘Point’. Object literal may only specify known properties, and ‘z’ does not exist in type ‘Point’. Structural typing normally allows extra properties, but object literals get this extra scrutiny to catch typos. The fix is to either remove the stray property or assign it to a variable first, which uses ordinary structural compatibility instead:

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

function logPoint(point: Point): void {
  console.log(point.x, point.y);
}

const point3D = { x: 1, y: 2, z: 3 };
logPoint(point3D);

Output:

1 2

Mistake 2: Assuming readonly Is Deep

readonly only protects the property itself from reassignment — it does not make the referenced value immutable:

interface Team {
  readonly members: string[];
}

const team: Team = { members: ["Ann", "Ben"] };
team.members.push("Cara");
console.log(team.members);

Output:

[ 'Ann', 'Ben', 'Cara' ]

This compiles fine — team.members = [] would be rejected, but mutating the array in place is untouched by readonly. To also block mutation of the array’s contents, type the property as readonly string[] (or ReadonlyArray<string>), which removes mutating methods like push from the type entirely:

interface Team {
  readonly members: readonly string[];
}

const team: Team = { members: ["Ann", "Ben"] };
// team.members.push("Cara"); // Error: Property 'push' does not exist on type 'readonly string[]'
console.log(team.members.length);

Output:

2

Best Practices

  • Use PascalCase for interface names (User, not IUser — the “I-prefix” convention is a C#/Java habit, not idiomatic TypeScript).
  • Prefer interface for object and class shapes you expect other code to extend or that a library consumer might want to augment via declaration merging.
  • Mark properties optional (?) rather than typing them as Type | undefined when the key may be entirely absent — the two are subtly different for tools like Object.keys and spread checks.
  • Use readonly string[] / readonly T[] when you want to prevent array mutation, not just reassignment of the array reference.
  • Favor small, composable interfaces combined with extends over one large interface with many optional fields.
  • When a class should satisfy a public contract, declare that intent explicitly with class Foo implements SomeInterface so the compiler checks it immediately, rather than relying on it “happening” to match.
  • Reach for a generic interface (interface Repository<T>) instead of duplicating near-identical interfaces for different data types.

Practice Exercises

  • Exercise 1: Define an interface Book with title: string, author: string, an optional isbn?: string, and a readonly publishedYear: number. Write a function describeBook(book: Book): string that returns a formatted sentence about the book, and call it with at least one object that omits isbn.
  • Exercise 2: Create an interface Animal with name: string and a method makeSound(): string. Extend it with a Dog interface that adds breed: string. Implement a class Labrador implements Dog and log the results of calling its makeSound().
  • Exercise 3: Write a generic interface Stack<T> with methods push(item: T): void, pop(): T | undefined, and peek(): T | undefined. Implement a class that satisfies it for numbers, then push three numbers on and log the result of peek(). Expected output for pushing 1, 2, 3: 3.

Summary

  • An interface describes the shape of an object — its properties, their types, and its methods — and is checked structurally, not by name.
  • Interfaces are fully erased at compile time; they produce zero runtime JavaScript.
  • ? marks a property optional; readonly prevents reassignment of that property (but not deep mutation of its value).
  • extends lets one interface build on another; classes opt in explicitly with implements.
  • Interfaces support declaration merging — redeclaring the same name adds members rather than erroring — which type aliases cannot do.
  • Object literals get an extra “excess property check” that plain variables don’t, which is a common source of confusing errors.
  • Use interface for extensible object/class shapes and type for unions, tuples, and non-object shapes.