Phase 3 · Classes, enums and modules · Lesson 3.1
IntermediateClasses
How TypeScript types JavaScript classes: fields, access modifiers vs #private, readonly, abstract classes, implements, override, polymorphic this, and why classes are still compared structurally.
25 min
Classes are plain JavaScript. What TypeScript adds is a layer of compile-time rules on top: which fields exist, who may touch them, and what a subclass must provide. Interviewers love classes because the line between "checked by TypeScript" and "enforced at runtime" is sharp here, and most people blur it.
Fields and constructors
In TypeScript you declare a field before you use it. A field can have a type, an initializer, or both.
class Counter {
count = 0; // type inferred as number
label: string; // declared, assigned in the constructor
constructor(label: string) {
this.label = label;
}
increment(): number {
return ++this.count;
}
}
const c = new Counter("clicks");
c.increment();
// @ts-expect-error -- Property 'total' does not exist on type 'Counter'.
c.total;Assigning to this.total without declaring total is an error too. Unlike plain JavaScript, a class's shape is fixed by its declarations.
strictPropertyInitialization and the ! escape hatch
Under strict, strictPropertyInitialization requires every non-optional field to be definitely assigned by the end of the constructor. The check only looks at the constructor body itself, not at methods it calls.
class Connection {
// @ts-expect-error -- Property 'url' has no initializer and is not definitely assigned in the constructor.
url: string;
retries?: number; // optional: may be undefined, no error
socket!: WebSocket; // definite assignment assertion: "trust me"
constructor() {
this.setUp();
}
setUp() {
this.url = "wss://example.com";
this.socket = new WebSocket(this.url);
}
}The ! after socket is a definite assignment assertion. It silences the check and nothing else: if you are wrong, you get undefined at runtime with a type that says WebSocket. Prefer assigning in the constructor, a field initializer, or an optional ? field. Reserve ! for fields set by a framework or an init() you control.
Parameter properties
Writing a field, a constructor parameter and an assignment for every value is repetitive. A parameter property does all three: put an access modifier (public, private, protected) or readonly in front of a constructor parameter.
class User {
constructor(
public readonly id: string,
private email: string,
) {}
domain() {
return this.email.split("@")[1];
}
}
const u = new User("u1", "ada@example.com");
const id = u.id;
// ^? const id: stringTwo gotchas worth knowing:
- Parameter properties are not plain JavaScript: TypeScript has to generate
this.id = idin the constructor. That makes them incompatible with--erasableSyntaxOnly(covered in the enums lesson). - A field initializer that reads a parameter property can run too early. With modern class fields (
targetES2022+, whereuseDefineForClassFieldsis on by default), field initializers run before the constructor body assigns parameter properties, and TypeScript catches it:
class Price {
// @ts-expect-error -- Property 'amount' is used before its initialization.
doubled = this.amount * 2;
constructor(public amount: number) {}
}public, private, protected vs #private
TypeScript has three access modifiers:
| Modifier | Accessible from |
|---|---|
public (default) | anywhere |
protected | the class and its subclasses |
private | the declaring class only |
The catch: these are compile-time only. After emit, a private field is an ordinary property. Anyone can read it at runtime, and even TypeScript lets you through with bracket notation:
class Vault {
private pin = "1234";
#code = "9999"; // ECMAScript private field
hasSameCode(other: Vault) {
return this.#code === other.#code; // allowed: same class
}
}
const v = new Vault();
// @ts-expect-error -- Property 'pin' is private and only accessible within class 'Vault'.
v.pin;
v["pin"]; // allowed! bracket access is an intentional escape hatch
console.log(JSON.stringify(v)); // {"pin":"1234"} — private is still a real property#code is different. # fields are a JavaScript feature (ES2022), enforced by the runtime: they don't show up in Object.keys, JSON.stringify or bracket access, and reading one from outside the class body is a syntax error in JavaScript itself.
private | #private | |
|---|---|---|
| Enforced by | TypeScript only | the JavaScript engine |
Bracket access obj["x"] | allowed | impossible |
Visible in JSON.stringify / Object.keys | yes | no |
| Subclass can declare the same name | no (error) | yes (separate slots) |
| Brand check | no | #x in obj |
# fields also give you an ergonomic brand check: #code in obj is true only for objects created by this class's constructor.
class Token {
#secret = crypto.randomUUID();
static isToken(value: unknown): value is Token {
return typeof value === "object" && value !== null && #secret in value;
}
}Quick check
Which line is a compile error?
class Account {
private balance = 100;
}
const a = new Account();
a.balance; // Line A
a["balance"]; // Line B
console.log(Object.keys(a)); // Line Creadonly
readonly fields can be assigned in their declaration or in the constructor, and nowhere else.
class Config {
readonly tags: string[] = [];
readonly env: string;
constructor(env: string) {
this.env = env; // fine: inside the constructor
}
rename() {
// @ts-expect-error -- Cannot assign to 'env' because it is a read-only property.
this.env = "prod";
}
}
const cfg = new Config("dev");
cfg.tags.push("beta"); // allowed: readonly is shallowreadonly protects the binding, not the value. The array itself is still mutable; use readonly string[] (or ReadonlyArray<string>) to stop push. And like private, it is erased: nothing stops mutation at runtime.
Getters and setters
Accessors look like properties from the outside. A getter without a setter makes the property read-only.
class Temperature {
#celsius = 0;
get fahrenheit(): number {
return this.#celsius * 1.8 + 32;
}
set fahrenheit(value: number | string) {
this.#celsius = (Number(value) - 32) / 1.8;
}
get kelvin() {
return this.#celsius + 273.15;
}
}
const t = new Temperature();
t.fahrenheit = "212"; // setter accepts number | string
const f = t.fahrenheit;
// ^? const f: number (the getter type)
// @ts-expect-error -- Cannot assign to 'kelvin' because it is a read-only property.
t.kelvin = 0;Since TypeScript 5.1 the getter and setter types can be unrelated; before that, the getter type had to be assignable to the setter type.
Static members
static members live on the class (the constructor function), not on instances.
class IdGenerator {
static #next = 1;
static readonly prefix = "id_";
static create(): string {
return IdGenerator.prefix + IdGenerator.#next++;
}
static {
// static initialization block: runs once when the class is defined
IdGenerator.#next = 100;
}
}
IdGenerator.create();
// @ts-expect-error -- Property 'create' does not exist on type 'IdGenerator'. Did you mean to access the static member 'IdGenerator.create' instead?
new IdGenerator().create();A static member cannot use the class's type parameters (class Box<T> { static empty: T } is an error), because there is one static side shared by every Box<T>.
A class is both a type and a value
class Point {} creates two things with the same name:
- a type
Point: the shape of an instance; - a value
Point: the constructor function, whose type is writtentypeof Point.
class Point {
static origin = new Point(0, 0);
constructor(public x: number, public y: number) {}
}
const p: Point = new Point(1, 2); // instance type
const Ctor: typeof Point = Point; // constructor type, has .origin
Ctor.origin;
type Instance = InstanceType<typeof Point>;
// ^? type Instance = Point
function make(C: new (x: number, y: number) => Point) {
return new C(3, 4);
}
make(Point);new (...) => T is a construct signature: "something you can call with new". That's how you write factories that accept a class.
Abstract classes
An abstract class can't be instantiated. It can declare abstract members that subclasses must implement, next to normal members they inherit.
abstract class Shape {
abstract area(): number;
abstract readonly name: string;
describe(): string {
return `${this.name} with area ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
readonly name = "circle";
constructor(private radius: number) {
super();
}
area() {
return Math.PI * this.radius ** 2;
}
}
// @ts-expect-error -- Cannot create an instance of an abstract class.
new Shape();
new Circle(2).describe();Unlike an interface, an abstract class exists at runtime and can carry shared implementation. To accept "any concrete subclass" as a value, use an abstract construct signature: abstract new () => Shape accepts both abstract and concrete classes, while new () => Shape accepts only concrete ones.
implements checks, it does not change the class
class A implements I asks TypeScript to verify that A is assignable to I. That's all. It does not copy types from I into A: method parameters are not contextually typed, and optional members are not added.
Quick check
Which lines error under strict?
interface Checkable {
check(name: string): boolean;
label?: string;
}
class NameChecker implements Checkable {
check(s) { // Line A
return s.length > 0;
}
}
new NameChecker().label; // Line BA class can implement several interfaces (implements A, B), and it can even implement another class's type (implements Point), because a class type is just a shape.
override and noImplicitOverride
The override keyword marks a method that replaces a base-class member. TypeScript then errors if the base has no such member, which catches typos and base-class renames:
class Logger {
log(message: string) {
console.log(message);
}
}
class TimestampLogger extends Logger {
override log(message: string) {
super.log(`${new Date().toISOString()} ${message}`);
}
// @ts-expect-error -- This member cannot have an 'override' modifier because it is not declared in the base class 'Logger'.
override logg(message: string) {}
}override alone is opt-in. Turn on noImplicitOverride in tsconfig.json and the reverse is enforced too: overriding a member without writing override becomes an error (This member must have an 'override' modifier because it overrides a member in the base class). Together they guarantee every override is intentional. noImplicitOverride is not part of strict; you enable it separately.
Polymorphic this types
Inside a class, this can be used as a type: "the type of whatever instance this is called on". It's what makes fluent APIs survive subclassing.
class QueryBuilder {
protected parts: string[] = [];
where(clause: string): this {
this.parts.push(`WHERE ${clause}`);
return this;
}
}
class PagedQueryBuilder extends QueryBuilder {
limit(n: number): this {
this.parts.push(`LIMIT ${n}`);
return this;
}
}
const q = new PagedQueryBuilder().where("age > 18").limit(10);
// ^? const q: PagedQueryBuilder▶ Try it in the TypeScript Playground
If where returned QueryBuilder instead of this, the chain would lose the subclass and .limit would be an error. You can also use this in parameter positions (equals(other: this)) and as a type guard return (isActive(): this is ActiveUser).
Classes are compared structurally
TypeScript's type system is structural, and classes are no exception. Two classes with the same public shape are interchangeable, and an object literal can stand in for a class instance:
class Cat {
constructor(public name: string) {}
}
class Dog {
constructor(public name: string) {}
}
const pet: Cat = new Dog("Rex"); // fine: same shape
const fake: Cat = { name: "Tom" }; // fine too
console.log(fake instanceof Cat); // false at runtime!This is the classic trap: a value typed Cat is not guaranteed to pass instanceof Cat.
The exception: private, protected and # members make a class nominal. A type with a private member is only compatible with types whose private member comes from the same declaration.
class Meters {
private readonly unit = "m";
constructor(public value: number) {}
}
class Feet {
private readonly unit = "m";
constructor(public value: number) {}
}
// @ts-expect-error -- Types have separate declarations of a private property 'unit'.
const m: Meters = new Feet(3);That's a cheap way to get branded, non-interchangeable class types.
Quick check
What does this print, and does it compile?
class Settings {
theme = "dark";
}
const s: Settings = { theme: "light" };
console.log(s instanceof Settings);Spot the error
This class looks fine, and compiles in plain JavaScript. Why does TypeScript reject it under strict?
class Cache {
store: Map<string, string>;
constructor() {
this.reset();
}
reset() {
this.store = new Map();
}
}Show the answer
strictPropertyInitialization only looks at the constructor body. It doesn't follow the call into reset(), so store is reported as Property 'store' has no initializer and is not definitely assigned in the constructor. The cleanest fix is an initializer (and reset just replaces it):
class Cache {
store = new Map<string, string>();
reset() {
this.store = new Map();
}
}If the assignment genuinely has to happen elsewhere, store!: Map<string, string>; works, but it moves the responsibility from the compiler to you.
Recap
- Declare fields before use;
strictPropertyInitializationrequires assignment in the constructor (or an initializer,?, or!). - Parameter properties (
constructor(private x: T)) declare and assign in one go, but aren't erasable syntax. public/protected/privateare compile-time only;#privateis enforced at runtime.readonlyis shallow and compile-time only; a getter without a setter is read-only.- A class name is both an instance type and a constructor value (
typeof C). - Abstract classes can't be instantiated and can mix abstract and concrete members.
implementsonly checks; it never types parameters or adds members.override+noImplicitOverridemake every override explicit.- Return
thisfor fluent methods that work in subclasses. - Classes are structural, except that private/protected/
#members make them nominal.
Interview cards
1 / 8