Skip to main content
TypeScript · lesson 6 of 21
TypeScript beginner Lesson 6 of 21

Interfaces in TypeScript

Describe object shapes with interfaces: optional and readonly properties, methods, extends, index signatures, merging and implements, with real tsc output.

By SkillByExample · Updated

Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.

What Is an Interface?

An interface names the shape of an object: which properties it has and their types.

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: "alice@example.com" };
console.log(displayUser(alice));

const bob: User = { id: 2, name: "Bob" };
const carol: User = { id: 3, name: "Carol", email: "c@example.com", emial: "typo" };
$ npx tsc --noEmit --module nodenext user.ts
user.ts(14,7): error TS2741: Property 'email' is missing in type '{ id: number; name: string; }' but required in type 'User'.
user.ts(15,69): error TS2561: Object literal may only specify known properties, but 'emial' does not exist in type 'User'. Did you mean to write 'email'?

A missing property is an error, and so is an unknown one in an object literal — the excess property check exists to catch typos like emial. That check only applies to fresh literals. An object with extra properties that’s already in a variable is accepted, because it still has everything a User needs:

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

const row = { id: 4, name: "Dan", email: "dan@example.com", passwordHash: "x9f..." };
const user: User = row;

console.log(Object.keys(user));
$ npx tsc --noEmit --module nodenext structural.ts
$ npx tsx structural.ts
[ 'id', 'name', 'email', 'passwordHash' ]

TypeScript is structural: a value is a User if it has the right properties, whatever else it carries. The type says nothing about passwordHash, but the object still has it at runtime — so serialising a User straight into an API response would leak it. Types don’t strip fields; you have to copy the ones you want.

Optional and Readonly Properties

? marks a property that may be absent. readonly allows setting a property when the object is created, never after.

interface Product {
  readonly id: number;
  name: string;
  description?: string;
}

function summary(p: Product): string {
  return `${p.id}: ${p.name} — ${p.description ?? "no description"}`;
}

const widget: Product = { id: 1, name: "Widget" };
console.log(summary(widget));
console.log(summary({ id: 2, name: "Gadget", description: "Does things" }));

widget.name = "Widget Pro";
widget.id = 99;
console.log(widget.description.length);
$ npx tsc --noEmit --module nodenext optional-readonly.ts
optional-readonly.ts(16,8): error TS2540: Cannot assign to 'id' because it is a read-only property.
optional-readonly.ts(17,13): error TS18048: 'widget.description' is possibly 'undefined'.

Renaming is fine; changing id is not. Reading description without handling undefined is rejected — the ?? in summary is what the compiler wants. readonly is compile-time only: the emitted JavaScript contains no freeze, so code that bypasses the types (a cast, or plain JavaScript) can still mutate the object.

Methods

Interfaces describe behaviour as well as data. There are two ways to write a method, and under strict they’re checked differently.

interface Animal { name: string }
interface Dog extends Animal { bark(): string }

interface MethodStyle {
  handle(animal: Animal): void;
}
interface PropertyStyle {
  handle: (animal: Animal) => void;
}

const dogOnly = (dog: Dog) => console.log(dog.bark());

const a: MethodStyle = { handle: dogOnly };
const b: PropertyStyle = { handle: dogOnly };
$ npx tsc --noEmit --module nodenext methods.ts
methods.ts(14,28): error TS2322: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
  Types of parameters 'dog' and 'animal' are incompatible.
    Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

Both declarations promise that handle accepts any Animal, but dogOnly only handles dogs — passing it a cat would crash on bark(). The property-style signature catches that. The method-style one lets it through: method parameters are checked bivariantly for historical compatibility (for example, so Array<Dog> stays assignable to Array<Animal>). If you want the stricter check, write function-typed properties.

Extending Interfaces

extends builds on an existing interface; a child may extend several parents.

interface Entity { id: number; createdAt: Date }
interface Serializable { serialize(): string }

interface User extends Entity, Serializable {
  name: string;
}

const user: User = {
  id: 1,
  createdAt: new Date(Date.UTC(2026, 0, 1)),
  name: "Alice",
  serialize() {
    return JSON.stringify({ id: this.id, name: this.name, createdAt: this.createdAt });
  },
};
console.log(user.serialize());

interface BadUser extends Entity {
  id: string;
}
$ npx tsc --noEmit --module nodenext extends.ts
extends.ts(18,11): error TS2430: Interface 'BadUser' incorrectly extends interface 'Entity'.
  Types of property 'id' are incompatible.
    Type 'string' is not assignable to type 'number'.

User has every property of both parents. A child can narrow an inherited property’s type but not change it to something incompatible, which is why BadUser fails. Remove it and run:

$ npx tsx extends.ts
{"id":1,"name":"Alice","createdAt":"2026-01-01T00:00:00.000Z"}

JSON.stringify turns the Date into an ISO string — it’s a Date in memory and a string on the wire, a mismatch no interface records for you.

Index Signatures

When the keys aren’t known in advance — headers, a lookup table — an index signature types all of them at once.

interface Headers {
  [name: string]: string;
}

const headers: Headers = { "Content-Type": "application/json" };
headers["X-Request-Id"] = "abc123";
console.log(headers, headers["Authorization"]);

headers["Retry-After"] = 30;

interface Config {
  debug: boolean;
  [key: string]: string;
}
$ npx tsc --noEmit --module nodenext index-signature.ts
index-signature.ts(9,1): error TS2322: Type 'number' is not assignable to type 'string'.
index-signature.ts(12,3): error TS2411: Property 'debug' of type 'boolean' is not assignable to 'string' index type 'string'.

Every known property must fit the index signature, so debug: boolean clashes with [key: string]: string — use string | boolean, or unknown, in the signature. Also note headers["Authorization"] is typed string even though it’s missing at runtime: remove the two error lines and it prints undefined.

$ npx tsx index-signature.ts
{ 'Content-Type': 'application/json', 'X-Request-Id': 'abc123' } undefined

The compiler option noUncheckedIndexedAccess changes that lookup’s type to string | undefined. It’s off under plain strict, but tsc --init in TypeScript 6 turns it on.

Declaration Merging

Declaring the same interface twice merges the two. That’s how you add fields to a type you don’t own — for example a library’s request object.

export interface Settings {
  locale: string;
}

export const defaults: Settings = { locale: "en-US" };
import type { Settings } from "./settings.js";

declare module "./settings.js" {
  interface Settings {
    theme: "light" | "dark";
  }
}

export function describe(s: Settings): string {
  return `${s.locale} / ${s.theme}`;
}
$ npx tsc --noEmit --module nodenext settings.ts theme-plugin.ts
settings.ts(5,14): error TS2741: Property 'theme' is missing in type '{ locale: string; }' but required in type 'Settings'.

declare module reopens the module’s Settings and adds theme. Now defaults — which only has locale — no longer satisfies it: the merged interface applies everywhere, including the module that declared the original. This is the mechanism behind augmenting Express’s Request with a user property, and also its risk: an augmentation changes the type for the whole program. A type alias can’t be merged at all:

type Point = { x: number };
type Point = { y: number };

interface Size { width: number }
interface Size { height: number }

const s: Size = { width: 1, height: 2 };
console.log(s);
$ npx tsc --noEmit --module nodenext type-merge.ts
type-merge.ts(1,6): error TS2300: Duplicate identifier 'Point'.
type-merge.ts(2,6): error TS2300: Duplicate identifier 'Point'.

interface versus type

Both describe object shapes, and for plain objects either works. The differences:

interfacetype
Object shapesYesYes
Unions, tuples, mapped and conditional typesNoYes
Declaration mergingYesNo — duplicate identifier
extends / implementsYes& for composition; a class can implement an object type alias

A common convention: interface for object shapes and public contracts, type for everything else.

Implementing an Interface in a Class

implements makes the compiler verify that a class provides everything an interface requires.

interface Animal {
  name: string;
  speak(): string;
}

class Dog implements Animal {
  constructor(public name: string) {}
  speak() {
    return "Woof!";
  }
}

class Fish implements Animal {
  name = "Nemo";
}

function makeNoise(animal: Animal): void {
  console.log(`${animal.name} says ${animal.speak()}`);
}
makeNoise(new Dog("Rex"));
$ npx tsc --noEmit --module nodenext implements.ts
implements.ts(13,7): error TS2420: Class 'Fish' incorrectly implements interface 'Animal'.
  Property 'speak' is missing in type 'Fish' but required in type 'Animal'.

implements doesn’t change the class or add anything at runtime — it’s a check. Without it, Fish would compile, and the error would only surface at the first place a Fish was passed where an Animal was expected.

A Worked Example: A Service Contract with Two Implementations

An interface for a service lets tests swap a real implementation for an in-memory one without touching the calling code.

interface User { id: number; name: string; email: string }
interface ApiResponse<T> { data: T; status: number }

interface UserService {
  getUser(id: number): Promise<ApiResponse<User | null>>;
  createUser(data: Omit<User, "id">): Promise<ApiResponse<User>>;
}

class InMemoryUserService implements UserService {
  private users: User[] = [{ id: 1, name: "Alice", email: "alice@example.com" }];

  async getUser(id: number) {
    const user = this.users.find((u) => u.id === id) ?? null;
    return { data: user, status: user ? 200 : 404 };
  }

  async createUser(data: Omit<User, "id">) {
    const user = { id: this.users.length + 1, ...data };
    this.users.push(user);
    return { data: user, status: 201 };
  }
}

async function welcome(service: UserService, id: number): Promise<string> {
  const { data, status } = await service.getUser(id);
  return data ? `Welcome, ${data.name}` : `No user ${id} (${status})`;
}

const service = new InMemoryUserService();
await service.createUser({ name: "Bob", email: "bob@example.com" });
console.log(await welcome(service, 2));
console.log(await welcome(service, 9));
$ npx tsc --noEmit --module nodenext service.ts
$ npx tsx service.ts
Welcome, Bob
No user 9 (404)

welcome only knows about UserService, so an HTTP-backed class that implements the same interface drops in without changes. The return type ApiResponse<User | null> forces welcome to handle a missing user — delete the data ? check and data.name becomes a compile error.

Practice

1. Add a readonly tags: string[] property to Product and try both p.tags = [] and p.tags.push("new"). Which one does tsc reject?

Solution
interface Product {
  readonly id: number;
  readonly tags: string[];
}

const p: Product = { id: 1, tags: ["sale"] };
p.tags.push("new");
console.log(p.tags);
p.tags = [];
$ npx tsc --noEmit --module nodenext practice-1.ts
practice-1.ts(9,3): error TS2540: Cannot assign to 'tags' because it is a read-only property.

Only the reassignment. readonly protects the property, not the array it points to. For an immutable array, type it readonly string[] — then push doesn’t exist on it.

2. Add deleteUser(id: number): Promise<ApiResponse<boolean>> to UserService without implementing it. Where does the error appear?

Solution
interface ApiResponse<T> { data: T; status: number }
interface UserService {
  getUser(id: number): Promise<ApiResponse<string | null>>;
  deleteUser(id: number): Promise<ApiResponse<boolean>>;
}

class InMemoryUserService implements UserService {
  async getUser(id: number) {
    return { data: id === 1 ? "Alice" : null, status: 200 };
  }
}
console.log(await new InMemoryUserService().getUser(1));
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(7,7): error TS2420: Class 'InMemoryUserService' incorrectly implements interface 'UserService'.
  Property 'deleteUser' is missing in type 'InMemoryUserService' but required in type 'UserService'.

On the class declaration, naming the missing method — the point of implements. Every implementation of the interface fails to compile until it catches up.

3. Change Config in index-signature.ts so it accepts debug: boolean alongside arbitrary string settings.

Solution
interface Config {
  debug: boolean;
  [key: string]: string | boolean;
}

const config: Config = { debug: true, region: "eu-west-1" };
config.logLevel = "info";
console.log(config);
config.retries = 3;
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(9,1): error TS2322: Type '3' is not assignable to type 'string | boolean'.

Widening the index signature to string | boolean makes room for debug. The trade-off: config.debug is still boolean, but any other key is string | boolean, so a number is rejected and a string read needs narrowing.

Frequently Asked Questions

What is the difference between interface and type?
Both describe object shapes, but interfaces are open (can be augmented/merged after declaration) and type aliases are closed. Use interface for object shapes and public API contracts; use type for unions, intersections, and computed types.
Can an interface extend a type alias?
Yes. An interface can extend a type alias as long as the type alias resolves to an object shape (not a union). This is useful when mixing third-party types with your own interfaces.
What is declaration merging?
Declaration merging lets you define the same interface in multiple places and TypeScript combines them into one. It is commonly used to augment third-party types, like extending Express's Request interface.
Do interfaces exist at runtime?
No. Interfaces are erased during compilation, so you cannot use instanceof with an interface or read its properties at runtime. To check a value's shape while the program runs, write a type guard function or validate it with a schema library.
Why does TypeScript reject an object literal with an extra property?
When an object literal is assigned directly to a typed variable or parameter, TypeScript runs an excess property check and rejects keys the type doesn't declare, because they are usually typos. The same object stored in a variable first and then passed along is accepted, since it still has every required property.