TSer/08 · Classes and interfaces
CHAPTER 08 · Modifiers, abstract, implements

Classes and interfaces

A class describes a shape and produces objects. TypeScript adds three access levels, a way to require that a member exists, and a way to check a class against an interface. All of it is checked while compiling, and almost all of it disappears from the output.

§01

Fields: if you declare one, it must exist

A field declaration is a promise about every instance. Under strict mode the compiler checks that you keep it.

What a field declaration promises

When you write name: string inside a class, you are telling the compiler that every object created from this class has a name, and it is a string. Code elsewhere is then allowed to call shop.name.toUpperCase() without checking for undefined.

That promise has to be kept, or the guarantee is worthless. The flag that enforces it is strictPropertyInitialization, one of the checks turned on by strict. If a declared field is never assigned, the compiler stops you before the code runs.

milk-tea-shop.ts · two ways to keep the promise
1class MilkTeaShop {
2 menu: string[] = []; // declared and initialized in the same line
3 name: string; // declared only, so the constructor must assign it
4
5 constructor(name: string) {
6 this.name = name; // the promise is kept here
7 }
8}
9
10const shop = new MilkTeaShop("Bloom Tea");
11shop.name.toUpperCase(); // safe: name is string, never undefined
Never assigned · error
1class MilkTeaShop {
2 name: string;
3 // Property 'name' has no
4 // initializer and is not
5 // definitely assigned in the
6 // constructor. ts(2564)
7}
Three ways to fix it
1class MilkTeaShop {
2 name = "Unnamed shop"; // 1. initialize it here
3 city: string;
4 boss!: string; // 3. "I promise it is assigned"
5
6 constructor(city: string) {
7 this.city = city; // 2. assign it in the constructor
8 }
9}

Two mistakes this check catches

Assigning the field from another method. If the constructor calls this.init() and init assigns the field, you still get ts(2564). The compiler does not follow assignments across methods, because it cannot tell whether that method always runs. Assign the field in the constructor itself.

Reaching for ! too early. boss!: string is a definite assignment assertion: it moves the guarantee from the compiler to you. The check stops reporting, but nothing is verified. If the value is really undefined, the failure happens at runtime. Use it only where something outside the class is known to assign the field, such as a framework that injects it.

A class declaration creates two things

class Order {} creates a type named Order and a value named Order. In a type position, Order means one instance of the class. The class object itself — the thing you write new in front of — has the type typeof Order. So let a: Order holds an instance, while let b: typeof Order = Order holds the class and lets you call new b(). An interface creates only a type, never a value.

Methods need no new rules. A method is a function that lives inside a class, and its parameters and return type are annotated exactly as chapter 02 described. The new material starts in the next section: saying which code is allowed to read a member.

§02

Three access levels: public, protected, private

Each level says which code may read the member. All three are checked only while compiling.

public means any code may read the member. It is the default, so writing no modifier is the same as writing public. protected narrows that to the class and any class that extends it. private narrows it further, to the class body itself. In code:

milk-tea-shop.ts · one class, three levels
1class MilkTeaShop {
2 public name = "Bloom Tea"; // public is the default
3 protected recipe = "tea base first"; // this class and its subclasses
4 private vaultCode = "8848"; // this class only
5
6 intro() {
7 // inside the class, all three are readable
8 return this.name + " / " + this.recipe + " / " + this.vaultCode;
9 }
10}
11
12class FranchiseShop extends MilkTeaShop {
13 train() {
14 return this.recipe; // ✓ a subclass may read a protected member
15 // this.vaultCode; // ✕ ts(2341): private, this class only
16 }
17}
18
19const shop = new MilkTeaShop();
20shop.name; // ✓ public
21// shop.recipe; // ✕ ts(2445): protected, not readable from outside
Access checker: pick a member, then pick where the access happens
MEMBER
FROM
const shop = new MilkTeaShop();
shop.vaultCode;
🔴
The compiler rejects this

A private member is readable only inside the class that declares it. A subclass does not count as inside, and neither does outside code.

🏦 private member · read from Outside (on an instance created with new)

Property 'vaultCode' is private and only accessible within class 'MilkTeaShop'. ts(2341)
Try all nine combinations. public passes in all three places, protected in two, private in one. Read the two error messages closely: the protected one ends with and its subclasses, the private one does not. Both checks happen only while compiling.

In a subclass constructor, super() comes first

If a subclass writes its own constructor, it must call super(...) before it touches this. Forgetting gives ts(2377): Constructors for derived classes must contain a 'super' call. This rule comes from JavaScript itself; TypeScript only reports it earlier.

One consequence is easy to miss. The subclass's own field initializers run after super() returns. So if a base-class constructor reads a field that the subclass initializes, it reads undefined. Anything the base constructor needs should be passed to it as an argument.

§03

private is a compile-time check; #field is a runtime one

private hides nothing once the code runs. This section shows exactly what survives compilation.

Chapter 01 said that types are erased when TypeScript compiles. Access modifiers are part of the type system, so they are erased too. private vaultCode becomes an ordinary property in the output, readable by anyone. JavaScript has its own private fields, written with a #, and those are a different mechanism. Compare the two:

TS private · checked, not hidden
1class Shop {
2 private vaultCode = "8848";
3}
4const s = new Shop();
5
6s.vaultCode; // ✕ ts(2341)
7s["vaultCode"]; // ✓ "8848" — allowed on purpose
8JSON.stringify(s);
9// {"vaultCode":"8848"}
JS #field · actually unreachable
1class Shop {
2 #vaultCode = "8848";
3}
4const s = new Shop();
5
6// s.#vaultCode ✕ syntax error
7s["#vaultCode"]; // undefined
8JSON.stringify(s);
9// {}

Notice line 7 on the left. s["vaultCode"] compiles with no error at all — no cast is needed. TypeScript allows bracket access to a private member on purpose, as an escape hatch for tests and older code. And a plain JavaScript file that imports this class sees nothing unusual: it reads s.vaultCode directly.

Source next to compiled output
The .ts you write
class Shop {
  private vaultCode = "8848";
}
The .js it produces
class Shop {
  vaultCode = "8848";
}
TRY TO READ ITshop["vaultCode"]🔓 "8848" — no error, no cast
The word private is erased. The compiled JavaScript holds an ordinary property. TypeScript even accepts shop["vaultCode"] in the source: bracket access to a private member is a deliberate escape hatch, so no cast is needed. At runtime JSON.stringify(shop), DevTools, and any plain JavaScript file all see the value.
TS PRIVATE
A compile-time boundary

No runtime cost, clear error messages, and it takes part in type compatibility (that is what §06 is about). Use it to mark which members are internal, so that other people editing the code do not reach into them by accident. That covers most of what encapsulation is for.

JS #FIELD
A runtime boundary

A JavaScript language feature, not a TypeScript one. It survives compilation and stays unreachable from outside the class while the program runs. TypeScript still type-checks it fully. Use it when code you do not control must not be able to reach the field, which mostly means libraries.

Common mistake: treating private as a security feature

A password or a token stored in a private field is not protected. One JSON.stringify(shop) prints it. So does the browser's DevTools, and so does any log line that serializes the object. #field does keep the value out of those places, but it is still plain text in memory and it is still in your bundle if you hard-coded it. Real secrets belong on a server, not in a field of either kind.

§04

Four conveniences: parameter properties, readonly, get/set, static

None of these is a new idea. They are four shortcuts that make a class shorter to write.

Parameter properties save the most typing. Put an access modifier in front of a constructor parameter, and that one line declares the field, receives the argument, and assigns it:

Written out · four lines of boilerplate
1class MilkTeaShop {
2 private db: Database;
3 readonly city: string;
4
5 constructor(db: Database, city: string) {
6 this.db = db;
7 this.city = city;
8 }
9}
Parameter properties · one step
1class MilkTeaShop {
2 constructor(
3 private db: Database,
4 readonly city: string,
5 ) {}
6}
7// A modifier on a constructor parameter
8// declares a field and assigns it.

Parameter properties emit code, so they cannot just be erased

This is the one feature in this chapter with no JavaScript equivalent. Everything else here is either erased or already JavaScript, but a parameter property makes the compiler generate a this.db = db statement that is not in your source. Node's built-in TypeScript support (22.18 and later) only strips types, so it rejects this syntax. The erasableSyntaxOnly flag reports it as ts(1294) if you want the compiler to warn you first. Use parameter properties freely with a normal build step; avoid them if Node must run your .ts files directly.

readonly blocks assignment after initialization. get / set let a pair of methods be used with property syntax. static puts a member on the class itself instead of on each instance. All three in one example:

order.ts · readonly + get/set + static
1class Order {
2 readonly id: string; // cannot be assigned again after the constructor
3 private _sugar = 50; // where the value is actually stored
4
5 constructor(id: string) {
6 this.id = id; // the constructor is the last chance to assign it
7 }
8
9 get sugar() { return this._sugar; } // read it like a property
10 set sugar(v: number) { // writing goes through this check
11 if (v < 0 || v > 100) throw new Error("sugar must be 0-100");
12 this._sugar = v;
13 }
14}
15
16class OrderId {
17 static prefix = "MT"; // lives on the class, not on instances
18 static next(n: number) {
19 return OrderId.prefix + "-" + String(n).padStart(4, "0");
20 }
21}
22
23const o = new Order(OrderId.next(7)); // "MT-0007"
24o.sugar = 30; // ✓ goes through the setter
25// o.id = "MT-9"; // ✕ Cannot assign to 'id' because it
26 // is a read-only property. ts(2540)
readonly is a compile-time check like private, and it is narrower than it looks: it blocks assignment to the property, not changes to the object the property points at. With readonly tags: string[], the line o.tags = [] is an error but o.tags.push("x") is allowed. To stop changes at runtime you need Object.freeze.

A getter and its setter may have different types

Since TypeScript 4.3 the setter may accept a wider type than the getter returns, as long as the getter's type is assignable to the setter's. That is useful when you want to accept several input forms but always hand back one: set sugar(v: number | string) together with get sugar(): number lets callers write o.sugar = "30" while o.sugar still reads as a number.

§05

abstract and implements: an unfinished class, and a shape check

One says a subclass must finish the job. The other says a class must match an interface.

An abstract class is an unfinished class. Some members are implemented; the ones marked abstract have only a signature. You cannot create an instance of it, and a concrete subclass must implement every abstract member before it can be used:

staff.ts · an unfinished class
1abstract class Staff {
2 abstract greet(): string; // signature only, a subclass must write the body
3
4 clockIn() { // already implemented, every subclass inherits it
5 console.log(this.greet() + ", clocking in");
6 }
7}
8
9new Staff();
10// ✕ Cannot create an instance of an abstract class. ts(2511)
11
12class Barista extends Staff {
13 greet() { return "I am the barista"; } // the missing piece, supplied
14}
15new Barista().clockIn(); // "I am the barista, clocking in"

implements is a check, not an inheritance. An interface lists the members a class must have; implements asks the compiler to verify that the class has them. It adds nothing to the class. A checkout counter does not care which payment service is behind it, only that the object can pay and refund:

payment.ts · the interface lists it, the class supplies it
1interface PaymentProvider {
2 pay(amount: number): Promise<string>; // returns a transaction id
3 refund(txId: string): Promise<void>;
4}
5
6class WeChatPay implements PaymentProvider {
7 async pay(amount: number) { // the type is written here, not copied
8 // call the WeChat SDK here
9 return "wx_" + Date.now();
10 }
11 async refund(txId: string) { /* ... */ }
12}
13
14class AliPay implements PaymentProvider {
15 async pay(amount: number) { return "ali_" + Date.now(); }
16 async refund(txId: string) { /* ... */ }
17}
📜 interface PaymentProvider — the contract
  • pay(amount: number): Promise<string>
  • refund(txId: string): Promise<void>
It lists which members must exist. It contains no implementation, and it disappears when the code is compiled.
💚 WeChatPay✓ pay ✓ refundCalls the WeChat SDK. Both members exist with matching types, so the check passes.
💙 AliPay✓ pay ✓ refundA different SDK, the same two members. The check passes just the same.
🩶 CashPay✓ pay ✕ refund missingClass 'CashPay' incorrectly implements interface 'PaymentProvider'. Property 'refund' is missing in type 'CashPay' but required in type 'PaymentProvider'. ts(2420)

The value of the interface shows up at the call site. MilkTeaShop takes any object that matches PaymentProvider, so swapping the payment service changes nothing inside the shop:

milk-tea-shop.ts · depend on the interface, not on one class
1class MilkTeaShop {
2 constructor(private payment: PaymentProvider) {} // any matching class fits
3
4 async checkout(total: number) {
5 const txId = await this.payment.pay(total);
6 return "Paid. Transaction " + txId;
7 }
8}
9
10new MilkTeaShop(new WeChatPay()); // today
11new MilkTeaShop(new AliPay()); // tomorrow, and the shop code is unchanged

Three details that are easy to get wrong

1. implements does not change the type of the class. It adds no members and it does not annotate anything for you. If you write pay(amount) { … } with no type, amount does not become number; it is an implicit any, which is an error under strict (ts(7006)). Write the parameter types yourself; the compiler then compares what you wrote against the interface.

2. abstract class vs interface. An interface only describes a shape and is erased at compile time — at runtime the name does not exist. An abstract class is a real class: it exists at runtime, it can hold implemented methods and state, and a subclass inherits them. Use an abstract class when subclasses should inherit working code; use an interface when you only want to describe a shape.

3. One extends, many implements. class Shop extends Building implements Payable, Refundable { } is valid. A class has exactly one base class, but it can be checked against any number of interfaces.

§06

Back to chapter 04: classes compare by shape, except for private

The last missing piece of structural typing.

Chapter 04 said that TypeScript compares types by their shape, not by their name. Classes are no exception: two unrelated classes with the same members are interchangeable. There is one exception, and it appears as soon as a class has a private or protected member. Step through it:

Two classes with the same shape, and what private changes
class PaperCupsize: Sizefill(ml: number): void
✓ Compatible
class PlasticCupsize: Sizefill(ml: number): void
const cup: PaperCup = new PlasticCup(); // ✓
The rule from chapter 04 applies to classes as well: the compiler compares shapes, not names. These two classes declare the same members, so a PlasticCup is accepted where a PaperCup is expected.
1 / 4
playground.ts · reproduce it yourself
1class PaperCup {
2 private stock = 0;
3 fill(ml: number) {}
4}
5class PlasticCup {
6 private stock = 0;
7 fill(ml: number) {}
8}
9
10const cup: PaperCup = new PlasticCup();
11// ✕ Type 'PlasticCup' is not assignable to type 'PaperCup'.
12// Types have separate declarations of a private
13// property 'stock'. ts(2322)
Delete both private stock lines and the assignment compiles. Or keep them and move the declaration to a shared base class that both cups extend — then there is only one declaration, and the two types are compatible again.

The complete rule for structural typing

Types are compared by shape, except that two types with private or protected members are compatible only if those members come from the same declaration (inherited from a shared base class counts). This is also how libraries fake nominal typing: give a class one private member, and no same-shaped stranger can be used in its place.

§07

Practice

Four tasks in the TypeScript Playground: trigger the access errors, sign a contract, read the compiled output, and finish an abstract class.

§08

Quiz

Nine questions on what the compiler checks, and on what survives compilation.

QUESTION 01 / 9

class Shop { private vaultCode = "8848" } — after it is compiled to JavaScript, what happens to vaultCode?

QUESTION 02 / 9

Which sentence states the difference between protected and private correctly?

QUESTION 03 / 9

Under strict, name: string; reports ts(2564): declared but never assigned. Which change does not make the error go away?

QUESTION 04 / 9

You want a class that cannot be instantiated directly, and whose missing members a subclass must implement. Which keyword goes in front of class?

QUESTION 05 / 9

In class WeChatPay implements PaymentProvider, what does implements actually do?

QUESTION 06 / 9

class CashPay implements PaymentProvider { pay(amount) { … } } amount has no annotation. What is its type?

QUESTION 07 / 9

About the parameter property constructor(private db: Database) {}, which statements are correct? (Choose all that apply.)

QUESTION 08 / 9

PaperCup and PlasticCup have identical members, and each declares its own private stock = 0. What does const cup: PaperCup = new PlasticCup() do?

QUESTION 09 / 9

You need a field that code outside the class cannot read while the program runs. Which one do you use?

What to take away from this chapter
  • A declared field must actually be assigned: initialize it where you declare it, assign it in the constructor, or take responsibility with !. strictPropertyInitialization enforces this, and it does not follow assignments made in other methods.
  • Three access levels: public is the default, protected adds subclasses, private is the class body only. All three are compile-time checks.
  • private is erased. In the compiled output it is an ordinary property, and even in TypeScript s["field"] reads it without a cast. For a field that is unreachable at runtime, use JavaScript's #field.
  • A parameter property replaces four lines with one, but it is the only feature here that generates code. A runtime that only strips types, such as Node running .ts files directly, rejects it.
  • abstract marks a class that cannot be instantiated and members a subclass must implement. implements only checks the class against an interface: it adds no members and infers no parameter types. One class can be checked against several interfaces.
  • Classes are compared by shape, with one exception: once a class has a private or protected member, those members must come from the same declaration for the two types to be compatible.