TypeScript – Classes

August 9, 20268 min readUpdated 9/5/2026

Classes are JavaScript's, not TypeScript's — class, extends, static and #private all run without a compiler. What TypeScript adds is field types, access modifiers, abstract, implements, and one shorthand that some builds refuse to compile.

Fields and constructors

export class ApiError extends Error {
  readonly status: number;
  readonly body: ApiErrorBody | null;

  constructor(status: number, message: string, body: ApiErrorBody | null) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.body = body;
  }

Fields are declared with their types before the constructor. Under strict, every declared field must actually be assigned in the constructor — the strictPropertyInitialization check — or the compiler will point out that it could be undefined despite the type saying otherwise.

Three ways out when a field genuinely is assigned elsewhere: give it a default, widen the type to include undefined, or use the definite assignment assertion !:

class Loader {
  private data!: Order[];   // "trust me, something assigns this"
}

That last one is a promise you are making with no evidence, so treat it as you would as — occasionally right, usually a sign the design wants rethinking.

Subclassing Error

The class above is worth pausing on, because it is the most common custom class in a frontend and it has two traps.

this.name must be set by hand. Without it the error reports itself as Error, which makes logs harder to read than they need to be.

instanceof can break. When compiling to ES5, subclassing a built-in loses the prototype chain and err instanceof ApiError comes back false — silently, for every check in the codebase. The Angular version of the same class guards against it:

    // TypeScript compiling to ES5 breaks `instanceof` for subclassed built-ins. This app targets
    // ES2022 so it is not strictly needed, but it costs one line and removes a nasty trap.
    Object.setPrototypeOf(this, ApiError.prototype);

Both apps target ES2022, so neither needs it. It is one line, and the failure it prevents is the kind that takes an afternoon.

A class can also hold behaviour, which is the reason to use one here rather than a plain object:

  /** Field errors as a lookup, for rendering next to inputs. */
  fieldErrors(): Record<string, string> {
    const result: Record<string, string> = {};
    for (const sub of this.body?.errors ?? []) {
      if (sub.field) result[sub.field] = sub.message;
    }
    return result;
  }

public, private, protected

The usual three, with public the default:

class CartService {
  public readonly itemCount = 0;   // `public` is redundant
  protected items: CartItem[] = [];
  private taxRate = TAX_RATE;
}

The critical thing about TypeScript's private: it is compile-time only. It is erased, and at runtime the property is an ordinary one that any code can read:

const service = new CartService();
// service.taxRate;             // compile error
(service as any).taxRate;       // runs fine — the property is right there

JavaScript's own private fields, prefixed with #, are genuinely inaccessible:

class CartService {
  #taxRate = TAX_RATE;
}

// (service as any).#taxRate — a syntax error, not a workaround

Which to use? # when the value must really be inaccessible — a secret, or an invariant you cannot let anyone break. private when you are documenting intent to your own team, which is most of the time, and which keeps the field visible in a debugger.

One consequence of private being compile-time: two classes with the same shape but different private members are not compatible. Private members are the one place this structural language behaves nominally.

Parameter properties, and the flag that bans them

TypeScript's most-used class shorthand. Put a modifier on a constructor parameter and it becomes a field, assigned automatically:

class CartService {
  constructor(private readonly api: ApiService) {}
}

That is the whole class — this.api exists and is assigned. Written out it would be three lines saying api three times.

It is also a construct that has to emit code: the assignment does not exist in the source, so a compiler has to generate it. Which brings us back to the flag from lesson 9, set in the React app's config:

    "erasableSyntaxOnly": true,

Under it, parameter properties do not compile — same as enum, and for the same reason. A type-stripper deletes annotations; it does not synthesise assignments.

So the React half writes the long form, and its ApiError is what that looks like: three readonly declarations and three assignments in the constructor. The Angular half has experimentalDecorators and no such flag, so it uses parameter properties throughout — as does every NestJS codebase you will read.

Neither is wrong. The point is that this is a build-configuration question rather than a style one, and knowing which regime you are in tells you what your options are.

Getters and setters

class Cart {
  private items: CartItem[] = [];

  get itemCount(): number {
    return this.items.reduce((n, item) => n + item.quantity, 0);
  }

  set orderType(value: OrderType) {
    this._orderType = value;
  }
}

Accessors are called like properties. Since TypeScript 4.3 the getter and setter may have different types — useful for a setter that accepts several input forms and a getter that returns one normalised value.

A getter with no setter is read-only from outside, which is often a better way to expose derived state than a method, because the call site reads as data.

abstract

An abstract class cannot be instantiated and may declare members without implementations:

abstract class BaseStore<T> {
  protected items: T[] = [];

  abstract load(): Promise<T[]>;

  async refresh(): Promise<void> {
    this.items = await this.load();
  }
}

A subclass must implement load. The value over an interface is that refresh is shared real code — an interface can only describe, not provide.

Note that abstract is a TypeScript construct with no runtime representation: the emitted class is an ordinary one, and the "cannot instantiate" rule is enforced only by the compiler.

implements versus extends

They are often confused and do quite different things.

extends inherits — you get the parent's implementation. A class extends exactly one class.

implements checks — you get nothing, and the compiler verifies your class has the required members. A class may implement any number of types, and either an interface or a type:

export class MoneyPipe implements PipeTransform {
  transform(value: number | null | undefined): string {
    return formatMoney(value ?? 0);
  }
}

implements PipeTransform gives MoneyPipe nothing at all. It asserts that the class has a compatible transform, and then it is erased. Angular finds this pipe through the @Pipe decorator; the implements clause is there so a mistyped method name fails the build rather than the template.

One subtlety: implements does not add types. If the interface declares transform(value: number) and your method omits the annotation, the parameter is implicitly any — it is not filled in from the interface. Annotate the members yourself.

static members

static members belong to the class rather than to an instance, and they take the same modifiers:

  /** Build one from whatever Angular threw. */
  static from(error: unknown): ApiError {
    if (error instanceof ApiError) return error;

That is the Angular ApiError, and the static factory is doing something a constructor cannot: deciding whether to create a new instance. Given an ApiError it returns it unchanged; given an HttpErrorResponse it converts; given anything else it falls back. A constructor must always construct.

Static factories are also where you put alternative construction paths that would otherwise be overloads — Order.fromCart(cart) and Order.fromJson(body) read better than one constructor branching on its argument.

Generic classes

A class can take type parameters, usable throughout its members:

class Store<T> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  all(): readonly T[] {
    return this.items;
  }
}

const orders = new Store<Order>();

One restriction worth knowing: the parameter is not available in static members. A static belongs to the class itself, which exists once, while T varies per instance — so there is nothing for it to refer to.

A class is a type and a value

Declaring a class creates two things: a type describing its instances, and a value — the constructor — that exists at runtime.

let err: ApiError;                  // the instance TYPE
const Ctor: typeof ApiError = ApiError;   // the constructor VALUE

This is why instanceof ApiError works where instanceof SomeInterface cannot: the class survives compilation, the interface does not. It is also why a class is the right tool for something you need to narrow to at runtime — which is exactly why both halves of the pizza app model their API failures as a class rather than a plain object.

Do you need classes at all?

In a React codebase, mostly not. The pizza React app has exactly one class — ApiError — and it exists because instanceof is the cleanest way to recognise it. Components are functions, state lives in hooks, and helpers are plain exported functions.

Angular and NestJS are the opposite: services, pipes, directives and components are all classes, because their dependency injection identifies things by constructor. That is a framework decision rather than a language one.

Reach for a class when you have state and behaviour that genuinely belong together, or when you need runtime identity. Otherwise a function and a type will do, and will be easier to test.

Ordinary functions, mostly

One last framing, since this lesson has covered a lot of syntax. Everything above describes classes; almost none of it argues for them.

The questions worth asking before reaching for one: does this have state and behaviour that genuinely belong together? Do I need instanceof to recognise it later? Is a framework going to construct it for me? Three yeses and a class is right. Three nos and a function with a typed parameter will be shorter, easier to test, and easier to tree-shake.

The pizza codebase is a fair demonstration of both halves of that. Its React app has one class, for an error it needs to recognise with instanceof. Its Angular app has dozens, because Angular's injector constructs them. Same domain, same developer, opposite answers — decided by the framework rather than by preference.

If you are coming from a language where everything is a class, the adjustment worth making is that a module here already does what a class of static methods does — it groups related functions under a name, with private helpers that are simply not exported. money.ts in this app is exactly that, and it needs no class to be one.

Next

Generics — how the app's HTTP wrapper returns the right type for every endpoint without an overload per URL.