Classes in TypeScript: Access Modifiers, abstract, implements and override
What TypeScript adds to classes: property types and initialization, public/private/protected versus #private, readonly, abstract, implements, override, and a note on parameter properties.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
This class has two "hidden" properties. One is declared with TypeScript's private keyword, the other with JavaScript's # sign:
class Wallet {
private balance = 100;
#pin = 1234;
}
const wallet = new Wallet();
console.log(wallet.balance);
console.log(wallet["balance"]);
console.log(Object.keys(wallet));
Two questions: Which line doesn't compile? And if you delete that line and run the code, what does the console print? A hint: the two aren't the same thing. The answer is in the section where we put the two privates side by side.
Declaring and Initializing Properties
In the JavaScript series we covered classes, the constructor, class fields and inheritance with extends. We won't re-teach those here; we'll look at what TypeScript adds to classes. The first thing is declaring the types of properties in the class body:
class User {
name: string;
role = "member";
// Error: Property 'email' has no initializer and is not definitely assigned in the constructor.
email: string;
constructor(name: string) {
this.name = name;
}
}
We wrote the type of name explicitly, while the type of role was inferred as string from its initial value. email, however, errors. The strictPropertyInitialization setting from the strict family requires that a property typed as string actually holds a string once the constructor finishes. We gave email neither an initial value nor an assignment in the constructor, so its value would be undefined when the instance is created.
There are three honest ways to fix this: give it an initial value, assign it in the constructor, or make the property optional with email?: string. There's a fourth way as well: email!: string. Like the non-null assertion we saw earlier, it tells the compiler "trust me, I'll assign it." Don't use it unless you're sure the value really gets assigned somewhere else (in an init() method, for example); if you're wrong, you'll catch the bug at runtime after all.
public, private and protected
TypeScript lets you control where class members can be accessed from with three access modifiers. If you write nothing, the member is public, meaning it's accessible from anywhere.
class Logger {
protected prefix = "[app]";
public info(message: string): void {
console.log(`${this.prefix} ${message}`);
}
}
class RequestLogger extends Logger {
request(path: string): void {
this.info(`GET ${path}`);
console.log(this.prefix.length);
}
}
const logger = new RequestLogger();
logger.request("/users");
// Error: Property 'prefix' is protected and only accessible within class 'Logger' and its subclasses.
console.log(logger.prefix);
A private member can only be accessed from inside the class that declares it. A protected member can be accessed from that class and from classes that derive from it, but not from outside. Using this.prefix inside RequestLogger is fine; logger.prefix is outside the class, so it errors. Writing public explicitly isn't required; most codebases leave it out, though some teams prefer it for readability.
private or #private?
Now back to the opening question. The wallet.balance line doesn't compile:
error TS2341: Property 'balance' is private and only accessible within class 'Wallet'.
Delete that line and run the code, and you'll see this in the console:
100
[ 'balance' ]
Here's the crucial difference. TypeScript's private is a rule that exists only at compile time. When the types are erased, the word private is erased too, and what's left is an ordinary property. TypeScript deliberately allows bracket access like wallet["balance"] (as an escape hatch), Object.keys lists the property, and JSON.stringify writes it to the output. #pin, on the other hand, is JavaScript's own private field; it's truly hidden at runtime as well, it doesn't show up in Object.keys, and there's no way to reach it from outside the class. TypeScript catches this at compile time too: write wallet.#pin and you get Property '#pin' is not accessible outside class 'Wallet' because it has a private identifier.
Which one should you use? If you want real privacy, meaning the value must not be readable from outside in any way, use #. private is more of an agreement that says "this is an internal detail of the class, hands off"; being able to peek inside from test code or while debugging is sometimes useful. And don't forget: protected has no equivalent in JavaScript. For a member that should be shared with derived classes, TypeScript's modifiers are the only option.
readonly
The readonly we saw in object types works on class properties as well:
class Order {
readonly id: string;
readonly createdAt = new Date();
constructor(id: string) {
this.id = id;
}
}
const order = new Order("A-100");
// Error: Cannot assign to 'id' because it is a read-only property.
order.id = "A-200";
A readonly property can only be assigned where it's declared or inside the constructor. After that, it can't be changed, not even from the class's own methods. But this protection, too, exists only at compile time, and it's shallow: you can't assign a new array to a readonly tags: string[] property, but order.tags.push("gift") works. If you want to protect the contents as well, type it as readonly string[].
abstract Classes
Sometimes you don't want a class to have instances of its own; you want it to be a base that defines shared behavior, with derived classes filling in the missing pieces. That's what abstract is for:
abstract class Shape {
abstract area(): number;
describe(): string {
return `Area: ${this.area().toFixed(2)}`;
}
}
class Circle extends Shape {
#radius: number;
constructor(radius: number) {
super();
this.#radius = radius;
}
area(): number {
return Math.PI * this.#radius ** 2;
}
}
console.log(new Circle(2).describe()); // Area: 12.57
// Error: Cannot create an instance of an abstract class.
const shape = new Shape();
abstract area(): number has no body; it just says "every class that derives from me must write this method." describe() is an ordinary method, and it can safely call area() even though it hasn't been written yet. If Circle forgot to write area(), you'd get Non-abstract class 'Circle' does not implement inherited abstract member area from class 'Shape'. And new Shape() is rejected outright.
implements: Tying a Class to an Interface
Inheritance takes behavior from a class. implements takes nothing; it only checks that the class matches the shape an interface describes:
interface Greeter {
greet(name: string): string;
}
interface Named {
readonly label: string;
}
class FriendlyGreeter implements Greeter, Named {
readonly label = "friendly";
greet(name: string): string {
return `Hi, ${name}!`;
}
}
console.log(new FriendlyGreeter().greet("Ada")); // Hi, Ada!
A class can implement several interfaces, but it can extend only one class. If you forget to write greet, you get Class 'FriendlyGreeter' incorrectly implements interface 'Greeter'. One thing worth knowing: implements is only a check; it doesn't change the class's type. Because of the structural typing we saw earlier, a class with the same shape can be passed where a Greeter is expected even without implements. The value of implements is that it catches a mismatch while you're writing the class, on the class's own line.
override and noImplicitOverride
A derived class can rewrite a method from its base class. But what happens if you misspell the method name? JavaScript thinks you're defining a new method and quietly carries on. TypeScript's override keyword catches this:
class Animal {
speak(): string {
return "...";
}
}
class Dog extends Animal {
override speak(): string {
return "Woof";
}
}
class Cat extends Animal {
// Error: This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'. Did you mean 'speak'?
override speek(): string {
return "Meow";
}
}
override tells the compiler "this method overrides a method that exists in the base class," and if that isn't true, it reports an error. But as long as writing override is optional, it offers no protection when you forget it. That's what the noImplicitOverride setting is for: when it's on, every member that overrides a base class member must be marked override. Remove override from Dog above and you get This member must have an 'override' modifier because it overrides a member in the base class 'Animal'. This setting isn't part of the strict family, and in the tsconfig that tsc --init generates it sits as a commented-out line. I recommend turning it on in projects that use inheritance.
Common Pitfalls
The first pitfall: assuming implements passes parameter types on to the class. It doesn't. Even though the interface says greet(name: string), if you write greet(name) in the class you get Parameter 'name' implicitly has an 'any' type. You have to write the types of the class's methods yourself.
The second pitfall: treating private as a security measure. As you saw in Predict First, it hides nothing at runtime. If you're holding a password, a token or any value that truly mustn't leak, use #.
The third pitfall is a shortcut you'll often see in other codebases: parameter properties.
class Point {
// Error (with erasableSyntaxOnly on): This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
constructor(private x: number, private y: number) {}
}
Writing private, public or readonly in front of a constructor parameter automatically turns that parameter into a property. It looks short, but this syntax doesn't disappear when types are erased; the compiler has to add real JavaScript like this.x = x to the constructor. That's why it isn't "erasable": Node's type stripping rejects this code with an ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX error, and in a project with erasableSyntaxOnly on, tsc gives the error above. In this series we always declare properties explicitly. We'll talk about this kind of TypeScript-only syntax separately, together with enums and namespaces, in an upcoming post.
A Small Experiment
★☆☆ Add the line console.log(JSON.stringify(wallet)); to the Wallet example. Predict which of balance and #pin will show up in the output, then run it and check.
★★☆ Predict whether it compiles: add the method isRicherThan(other: Wallet): boolean { return this.balance > other.balance; } to the Wallet class. other.balance is another instance's private property; is TypeScript's private tied to the instance or to the class?
★★★ Write a Rectangle class that derives from Shape, with #width and #height fields, and implement area(). Then check it from the command line with npx tsc --noEmit --noImplicitOverride: do you need to write override for area()? Observe the difference between implementing an abstract method and overriding an existing one.
In this post we described the structure of classes with types. In the next post we'll do the reverse: derive types from existing values and objects. We'll see how to capture a value's type with type-level typeof, how to build a union from a type's keys with keyof, and how to use the two together as keyof typeof.