<< Home

Chapter 4: Objects, Classes, and Interfaces

TypeScript builds large codebases around object shapes, class features, and contracts defined by interfaces. This chapter explains how structural typing guides compatibility, how classes provide fields and accessors, how implements and extends guide reuse, and how abstract classes and mixin patterns help you model flexible hierarchies.

Structural typing

TypeScript uses structural typing; two values are compatible when their shapes match. This differs from nominal typing where names decide compatibility. When you create object literals, the compiler also performs excess property checks to catch likely mistakes such as misspelled properties.

Structural compatibility

If a value has at least the required members of a target type, it is assignable. Extra members usually do not block assignment when the value is not a fresh object literal.

type Point = { x: number; y: number };
const p1 = { x: 1, y: 2, label: "A" };

let pt: Point = p1;  // OK; p1 has x and y
💡 Structural typing works for functions as well; parameter counts and types decide compatibility rather than function names.

Excess property checks for fresh literals

When assigning an object literal directly, the compiler treats it as fresh. Unknown properties trigger a diagnostic which often reveals typos.

type Options = { timeout: number; verbose?: boolean };

const good: Options = { timeout: 5000 };
const bad: Options = { timeout: 5000, verbsoe: true };  // Error; excess property

If you intend a flexible bag of properties, add an index signature or widen with an intermediate variable.

type Bag = { [key: string]: string | number };
const cfg: Bag = { host: "localhost", port: 5432, … };

Readonly properties and exactness

readonly marks intent and prevents writes. Structural typing still applies; a type with readonly members is compatible with another that matches those members as readable fields.

type ReadonlyPoint = { readonly x: number; readonly y: number };
const rp: ReadonlyPoint = { x: 0, y: 0 };
// rp.x = 1;  // Error; cannot assign to readonly
⚠️ Excess property checks apply to object literals only. Once a value is stored in a variable, extra properties no longer cause an error during assignment to a compatible target.

Classes, fields, accessors, and visibility

Classes in TypeScript compile to JavaScript classes. You can declare fields, use parameter properties, add get and set accessors, and control visibility with public, private, and protected. You can also mark fields readonly to communicate intent and prevent mutation.

Declaring fields

Fields can be declared with types and initializers. Parameter properties let you declare and initialize a field from a constructor parameter in one step.

class User {
  readonly id: string;
  constructor(public name: string, id: string) {
    this.id = id;
  }
}
const u = new User("Riley", "u-001");

Accessors with get and set

Accessors expose derived values or validate assignments. A setter can narrow input and guard invariants.

class Rectangle {
  constructor(private _w: number, private _h: number) {}
  get area(): number { return this._w * this._h; }
  get width(): number { return this._w; }
  set width(v: number) {
    if (v <= 0) throw new Error("width must be positive");
    this._w = v;
  }
}

Managing visibility

public is the default visibility type. private hides members from all external code. protected exposes members to subclasses while keeping them hidden from other consumers.

class Base {
  public a = 1;
  protected b = 2;
  private c = 3;
}
class Derived extends Base {
  demo() {
    this.a;     // OK
    this.b;     // OK
    // this.c;  // Error; private
  }
}
💡 Use private for invariants and protected for subclass hooks; this keeps APIs small and intention clear.

implements and extends

implements checks that a class satisfies an interface. extends creates a subclass that inherits members and behavior. These features combine structural contracts with reuse.

Using implements for contracts

A class can implement one or more interfaces. The compiler verifies that required members exist with compatible types.

interface Serializer<T> { serialize(value: T): string; }
class JsonSerializer<T> implements Serializer<T> {
  serialize(value: T): string { return JSON.stringify(value); }
}

Using extends for inheritance

Subclasses add or refine behavior. Use super to call the base constructor or methods.

class Animal {
  constructor(public name: string) {}
  speak(): string { return "..." }
}
class Dog extends Animal {
  speak(): string { return "woof" }
}

Multiple contracts

A class may implement many interfaces, and it may extend exactly one base class. Combine both when you need reuse and guarantees.

interface Describable { describe(): string; }
class Service extends JsonSerializer<unknown> implements Describable {
  describe(): string { return "Service using JSON" }
}
⚠️ Interfaces do not emit JavaScript; they vanish at runtime. Classes emit code and can hold behavior. Choose interfaces for shape contracts and classes for behavior.

Abstract classes

Abstract classes define partial implementations with abstract members that subclasses must provide. Mixin patterns compose behavior by creating class factories that add members to a base class. Use abstract classes when you want inheritable behavior with enforced overrides; use mixins when you prefer composition with small reusable traits.

Defining and extending an abstract class

Mark a class abstract to prevent direct construction. Mark methods abstract to require overrides.

abstract class Formatter {
  abstract format(value: unknown): string;
  prefix(v: string): string { return "[[" + v + "]]"; }
}
class UpperFormatter extends Formatter {
  format(value: unknown): string { return this.prefix(String(value).toUpperCase()); }
}
const f = new UpperFormatter();
// const bad = new Formatter();  // Error; abstract

A simple mixin factory

A mixin is a function that takes a constructor and returns a new constructor with added members. This composes features without deep inheritance.

type Ctor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Ctor>(Base: TBase) {
  return class extends Base {
    createdAt = new Date();
    touch() { this.createdAt = new Date(); }
  };
}

class Model { id = crypto.randomUUID(); }

class Note extends Timestamped(Model) {
  text = "";
}

const n = new Note();
n.touch();
💡 Mixins compose well with interfaces. Define an interface for the added surface and use implements on the mixed class to advertise the shape publicly.

Choosing between abstract classes and mixins

Prefer an abstract base when subclasses share a core algorithm with required hooks; prefer mixins when you want orthogonal capabilities such as logging, events, timestamps, or caching that apply to unrelated classes.