TypeScript Classes
TypeScript classes look almost identical to JavaScript classes, but they add a full type layer on top: typed properties, typed constructor parameters, access modifiers like private and protected, and compile-time checks for inheritance and interface implementation. None of this extra syntax survives into the compiled output — it exists purely to catch mistakes before your code ever runs. This lesson covers how TypeScript classes work, every piece of their syntax, and the mistakes that trip up even experienced developers.
Overview: How Classes Work in TypeScript
A JavaScript class is just a template for creating objects with shared methods and a prototype chain. TypeScript classes are the same runtime construct, but the compiler layers static type-checking on top of every part of the class: the types of properties, the types of constructor parameters, which members are accessible from outside the class, and whether a subclass correctly fulfills the contract of its parent class or any interfaces it implements.
Two ideas matter more than anything else when learning TypeScript classes:
1. Type erasure. Every type annotation, access modifier keyword, and interface is removed during compilation. The JavaScript that ships to the browser or Node.js has no idea what private or readonly ever meant — those are purely compile-time constructs enforced by tsc when it checks your source, not by the JavaScript runtime. This is different from truly private class fields (written with a # prefix), which are a real JavaScript feature and remain enforced at runtime even after compilation.
2. Structural typing. TypeScript does not care what a class is named when checking compatibility — it cares about the shape of its members. Two unrelated classes with identical public properties and methods are considered compatible types, even if neither one extends or implements the other. This is unlike languages such as Java or C#, which use nominal typing (where compatibility depends on explicit declarations). One caveat: once a class has any private or protected member, TypeScript also checks that both types originate from the exact same declaration, since there’s no way to structurally compare something that’s inaccessible outside the class.
Because of structural typing, an instance of one class can be assigned to a variable typed as another class, or as an interface, as long as the member shapes line up. Combined with type erasure, this means TypeScript classes give you rich compile-time guarantees with zero runtime cost — the safety is entirely front-loaded into the build step.
Syntax
Here is the general shape of a class declaration showing the most common members:
class ClassName<T> {
static staticProp: string = "shared value";
public prop1: T;
private prop2: number = 0;
protected prop3: boolean = false;
readonly prop4: string;
constructor(prop1: T, prop4: string) {
this.prop1 = prop1;
this.prop4 = prop4;
}
method1(): T {
return this.prop1;
}
static staticMethod(): string {
return ClassName.staticProp;
}
}
const instance = new ClassName<number>(42, "fixed");
console.log(instance.method1());
console.log(ClassName.staticMethod());
Output:
42
shared value
<T>— an optional generic type parameter, letting the class work with different types while keeping full type safety.static— belongs to the class itself, not to instances. Accessed asClassName.staticProp, never throughthisin an instance method unless the method is itself static.public(the default) — accessible from anywhere. You almost never need to write it explicitly.private— accessible only inside the declaring class. Enforced only by the compiler.protected— accessible inside the declaring class and any subclass, but not from outside.readonly— can only be assigned when declared or inside the constructor; every assignment after that is a compile error.- constructor — a special method run when the class is instantiated with
new. Parameters prefixed withpublic,private,protected, orreadonlybecome parameter properties — TypeScript automatically declares the field and assigns it, saving you from writingthis.x = xby hand.
| Modifier | Visible from | Enforced at runtime? |
|---|---|---|
public |
anywhere | N/A |
private |
declaring class only | No — compile-time only |
protected |
declaring class + subclasses | No — compile-time only |
#field (JS private field) |
declaring class only | Yes — real runtime privacy |
readonly |
anywhere (read); constructor only (write) | No — compile-time only |
Examples
Example 1: A Basic Class
class Car {
make: string;
model: string;
year: number;
constructor(make: string, model: string, year: number) {
this.make = make;
this.model = model;
this.year = year;
}
describe(): string {
return `${this.year} ${this.make} ${this.model}`;
}
}
const car = new Car("Toyota", "Corolla", 2023);
console.log(car.describe());
Output:
2023 Toyota Corolla
Every property has an explicit type, and the constructor parameters are typed the same way any function parameter would be. If you tried to call new Car("Toyota", "Corolla", "2023") with a string instead of a number for year, tsc would reject it immediately — this is the core value classes add over plain JavaScript.
Example 2: Access Modifiers, readonly, and static
class Employee {
static companyName = "Acme Corp";
readonly id: number;
private salary: number;
protected department: string;
constructor(id: number, private name: string, salary: number, department: string) {
this.id = id;
this.salary = salary;
this.department = department;
}
raiseSalary(amount: number): void {
this.salary += amount;
}
getSummary(): string {
return `${this.name} works in ${this.department} at ${Employee.companyName}`;
}
}
const emp = new Employee(1, "Priya", 75000, "Engineering");
emp.raiseSalary(5000);
console.log(emp.getSummary());
console.log(emp.id);
Output:
Priya works in Engineering at Acme Corp
1
Notice private name directly in the constructor parameter list — that’s a parameter property; TypeScript declares name as a private field and assigns it automatically, so there’s no need for a separate declaration or a manual this.name = name line. id is readonly, so it can be read from outside (emp.id works) but never reassigned after construction. salary is private, so emp.salary from outside the class would be a compile error, while department is protected, meaning only Employee and its subclasses can touch it.
Example 3: Abstract Classes, Interfaces, and Inheritance
interface Shape {
area(): number;
}
abstract class Polygon implements Shape {
constructor(protected sides: number) {}
abstract area(): number;
describe(): string {
return `A polygon with ${this.sides} sides and area ${this.area().toFixed(2)}`;
}
}
class Square extends Polygon {
constructor(private sideLength: number) {
super(4);
}
override area(): number {
return this.sideLength * this.sideLength;
}
}
class Triangle extends Polygon {
constructor(private base: number, private height: number) {
super(3);
}
override area(): number {
return (this.base * this.height) / 2;
}
}
const shapes: Polygon[] = [new Square(4), new Triangle(6, 3)];
shapes.forEach(shape => console.log(shape.describe()));
Output:
A polygon with 4 sides and area 16.00
A polygon with 3 sides and area 9.00
Polygon is abstract, meaning it can never be instantiated directly (new Polygon(4) is a compile error) — it only exists to be extended. Its area() method has no body, just a signature, forcing every concrete subclass to provide its own implementation. The implements Shape clause makes TypeScript verify that Polygon (and transitively its subclasses) satisfy the Shape interface’s shape. The override keyword documents intent and lets the compiler flag a typo (e.g. Area()) as an error instead of silently creating an unrelated new method. Both subclasses call super(4) / super(3) before touching this, satisfying the base class’s constructor contract.
Under the Hood: What the Compiler Does
When tsc compiles a class, it strips every type annotation, access modifier keyword, interface reference, and the abstract and override keywords entirely — none of these have any JavaScript runtime equivalent. What’s left is a plain ES class (or, for older targets, a constructor function with a manually wired prototype chain). Parameter properties are expanded into an explicit field declaration plus a this.x = x assignment inside the constructor, exactly as if you had written them by hand.
Because private and protected are erased, they only stop you from writing instance.secretField in TypeScript source — the compiled JavaScript object still has that property sitting on it, readable by anyone with a reference to the instance (for example via JSON.stringify, the browser console, or a type assertion like as any). If you need privacy that survives compilation and holds up at runtime, use a JavaScript private field (#balance) instead of the private keyword — see Mistake 2 below.
Before checking assignability between two class instances, the compiler also performs the abstract-member and interface checks structurally: it walks every method the interface or base class requires and confirms the implementing class provides a compatible signature. This all happens once, at compile time; there is no runtime interface object and no runtime instanceof Shape check possible, because Shape doesn’t exist after compilation.
Common Mistakes
Mistake 1: Forgetting to initialize a property (strictPropertyInitialization)
Under --strict, every declared property must either have a default value, be assigned in every code path of the constructor, or be explicitly marked optional/definite. Leaving one out is a compile error, not a warning:
class Person {
name: string;
age: number;
constructor(name: string) {
this.name = name;
}
}
tsc reports: Property 'age' has no initializer and is not definitely assigned in the constructor. The fix is to assign every declared property somewhere the compiler can see, usually by adding the missing constructor parameter:
class Person {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const p = new Person("Alex", 30);
console.log(p.name, p.age);
Output:
Alex 30
Mistake 2: Assuming private gives real runtime privacy
Because private is erased, the following compiles fine and leaks the “private” value at runtime via a type assertion:
class BankAccount {
private balance: number;
constructor(initial: number) {
this.balance = initial;
}
}
const acc = new BankAccount(100);
console.log((acc as any).balance);
Output:
100
This isn’t a tsc error — it’s a silent trap, since nothing stops as any or plain JavaScript callers from reaching the field. If you need enforcement that survives compilation, use a real ECMAScript private field with #:
class BankAccount {
#balance: number;
constructor(initial: number) {
this.#balance = initial;
}
getBalance(): number {
return this.#balance;
}
}
const acc = new BankAccount(100);
console.log(acc.getBalance());
Output:
100
Trying to write (acc as any).balance against this version returns undefined at runtime and cannot access #balance at all — the engine itself enforces the privacy, not just the compiler.
Mistake 3: Using this before calling super()
class Animal {
constructor(protected name: string) {}
}
class Dog extends Animal {
private breed: string;
constructor(name: string, breed: string) {
this.breed = breed;
}
}
tsc reports: 'super' must be called before accessing 'this' in the constructor of a derived class. A subclass constructor must call super(...) first so the base class can finish initializing its own fields before the subclass touches this:
class Animal {
constructor(protected name: string) {}
}
class Dog extends Animal {
private breed: string;
constructor(name: string, breed: string) {
super(name);
this.breed = breed;
}
describe(): string {
return `${this.name} is a ${this.breed}`;
}
}
const dog = new Dog("Rex", "Labrador");
console.log(dog.describe());
Output:
Rex is a Labrador
Best Practices
- Default to
public(omit the modifier) unless you have a specific reason to restrict access — over-restricting makes testing and extension harder. - Use parameter properties (
constructor(private x: number)) to cut boilerplate, but switch to full declarations once a class has many fields or needs default values, since long parameter-property lists get hard to read. - Reach for a real
#privatefield, not theprivatekeyword, whenever encapsulation is a genuine security or invariant requirement rather than just an API-design signal. - Mark overriding methods with
overrideand enable thenoImplicitOverridecompiler flag so renaming a base method surfaces every subclass that silently stopped overriding it. - Prefer composition or interfaces over deep inheritance chains; two or three levels of
extendsis usually the practical ceiling before code becomes hard to trace. - Use
abstractclasses when subclasses share real implementation code; use a plain interface when they only share a shape with no shared logic. - Avoid public mutable fields for anything that represents an invariant (like a balance or a count) — expose a method instead so the class controls how the value changes.
Practice Exercises
- Write a
Rectangleclass withprivatewidthandheightfields, a constructor, and public methodsarea()andperimeter(). Instantiate it and log both values. - Create an abstract class
Vehiclewith aprotectedtopSpeed: numberand an abstract methoddescribe(): string. Then write two subclasses,CarandBicycle, each implementingdescribe()differently, and log the description of one instance of each. - Take the flawed
BankAccountclass from Mistake 2 and rewrite it using a real#balanceprivate field, plus adeposit(amount: number): voidmethod that throws an error ifamountis negative. What doestscreport if you try to access#balancefrom outside the class entirely (not even viaas any)?
Summary
- TypeScript classes compile down to plain JavaScript classes — every type annotation, access modifier, and the
abstract/overridekeywords are erased at build time. public,private, andprotectedare compile-time-only checks; use ECMAScript#privatefields when you need privacy enforced at runtime.- Parameter properties (
constructor(private x: T)) let you declare and assign a field in one place. readonlypermits assignment only at declaration or inside the constructor.abstractclasses cannot be instantiated and can declare method signatures with no body, forcing subclasses to implement them.- A derived class constructor must call
super()before usingthis. - TypeScript uses structural typing for classes, except once a class has
private/protectedmembers, where nominal-style identity checks kick in.
