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 thereJavaScript's own private fields, prefixed with #, are genuinely inaccessible:
class CartService {
#taxRate = TAX_RATE;
}
// (service as any).#taxRate — a syntax error, not a workaroundWhich 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 VALUEThis 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.