TypeScript by Example

Classes

Constructor shorthand, access modifiers, readonly, abstract classes, implementing interfaces, and ECMAScript private fields.

TypeScript adds type-level access modifiers and compile-time privacy to JavaScript classes. For most use cases, the TypeScript additions are enough - but knowing when ECMAScript's #private fields are the right tool prevents a class of runtime bugs.

Constructor parameter shorthand declares and initializes fields in one step. Prefix a constructor parameter with public, private, protected, or readonly and TypeScript creates the corresponding field automatically.

class User {
  constructor(
    public readonly id: string,
    public name: string,
    private email: string,
    protected role: string = "member",
  ) {}
 
  describe(): string {
    return `${this.name} (${this.role}) <${this.email}>`;
  }
}
 
const user = new User("u-1", "Ada", "ada@example.com");
console.log(user.describe()); // "Ada (member) <ada@example.com>"
console.log(user.id);         // "u-1"
console.log(user.name);       // "Ada"
// user.email → TypeScript error: private

Abstract classes define a contract for subclasses without providing a complete implementation. Subclasses must implement every abstract method.

abstract class Shape {
  abstract area(): number;
  abstract perimeter(): number;
 
  describe(): string {
    return `area=${this.area().toFixed(2)}, perimeter=${this.perimeter().toFixed(2)}`;
  }
}
 
class Circle extends Shape {
  constructor(private radius: number) { super(); }
  area(): number { return Math.PI * this.radius ** 2; }
  perimeter(): number { return 2 * Math.PI * this.radius; }
}
 
class Rectangle extends Shape {
  constructor(private width: number, private height: number) { super(); }
  area(): number { return this.width * this.height; }
  perimeter(): number { return 2 * (this.width + this.height); }
}
 
const shapes: Shape[] = [new Circle(5), new Rectangle(4, 6)];
shapes.forEach((s) => console.log(s.describe()));
// area=78.54, perimeter=31.42
// area=24.00, perimeter=20.00

A class can implement an interface to guarantee its shape. TypeScript checks that every required property and method is present with the right type.

interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}
 
interface Validatable {
  validate(): boolean;
}
 
class Config implements Serializable, Validatable {
  private data: Record<string, string> = {};
 
  set(key: string, value: string): void { this.data[key] = value; }
  get(key: string): string | undefined { return this.data[key]; }
 
  serialize(): string { return JSON.stringify(this.data); }
  deserialize(json: string): void { this.data = JSON.parse(json) as Record<string, string>; }
  validate(): boolean { return Object.keys(this.data).length > 0; }
}

ECMAScript #private fields (the # prefix) are truly private at runtime - inaccessible from outside the class even via reflection. TypeScript's private keyword is erased at compile time and is accessible at runtime via (obj as any).field.

class SecureConfig {
  readonly name: string;
  #apiKey: string; // ECMAScript private - not accessible at runtime
 
  constructor(name: string, apiKey: string) {
    this.name = name;
    this.#apiKey = apiKey;
  }
 
  makeRequest(endpoint: string): string {
    // #apiKey available inside the class
    return `${endpoint}?key=${this.#apiKey}`;
  }
}
 
const config = new SecureConfig("prod", "secret-key-123");
console.log(config.name);          // "prod"
console.log(config.makeRequest("/api/data")); // "/api/data?key=secret-key-123"
 
// No runtime error - # fields aren't regular properties; cast access returns undefined
// console.log((config as any).apiKey); // undefined
// console.log((config as any)["#apiKey"]); // undefined

In production

TypeScript's private keyword is a type-system fiction - it's erased at runtime, so (obj as any).field bypasses it. This is fine for most internal APIs, but if you need runtime privacy (hiding API keys from console.log, protecting sensitive config from serialization libraries that walk prototypes), use ECMAScript #private fields. They're backed by a WeakMap in V8 and genuinely inaccessible outside the class. One practical check: add a compile-time assertion when a class must satisfy an interface - const _: SomeInterface = {} as MyClass catches the mismatch at definition time rather than at the call site where the class is used as an interface value.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free