TypeScript Classes
A TypeScript class is a JavaScript class with type annotations bolted on. The runtime semantics
are unchanged — same prototype chain, same new, same inheritance. What TypeScript adds is a
layer of compile-time bookkeeping: field types, access modifiers, abstract, implements, and a
shorthand for the constructor boilerplate you would otherwise write by hand.
That framing matters, because roughly half of what a TypeScript class gives you disappears at build time. Knowing which half is which is most of what this article is about.
What a TypeScript class actually adds
Start with the version that would work in plain JavaScript, plus types:
class BankAccount {
owner: string
balance: number
constructor(owner: string, balance: number) {
this.owner = owner
this.balance = balance
}
deposit(amount: number): void {
this.balance += amount
}
}
const account = new BankAccount('Ada', 100)
account.deposit(50)Two field declarations, a typed constructor, a typed method. Strip the annotations and you have the JavaScript class you already know.
The field declarations are doing real work under strict. TypeScript's
strictPropertyInitialization check insists that every declared property is assigned before
anyone can read it, which is why a bare balance: number with no constructor assignment raises
TS2564: Property has no initializer. There are three honest answers to it:
class HttpService {
// ✅ a default at the declaration site
baseUrl = 'https://api.example.com'
// ✅ optional — the type admits it might be missing
token?: string
// ✅ a promise that a framework assigns it before first read
client!: object
}Reach for ! last. It silences the check without making the property any safer.
Parameter properties cut the boilerplate
Assigning constructor arguments to identically named fields is tedious enough that TypeScript gives it dedicated syntax. Put an access modifier on a constructor parameter and the field is declared and assigned for you:
class ConciseAccount {
constructor(
public owner: string,
private balance: number,
) {}
deposit(amount: number): void {
this.balance += amount
}
}Same class as the one above, six lines shorter. This is one of the few places where TypeScript emits code rather than erasing it, and it is genuinely worth using.
The modifier is what triggers the shorthand. A bare constructor(owner: string) stays an ordinary
parameter that vanishes when the constructor returns, so if you drop the public you also drop the
field — a quiet edit that turns this.owner into a compile error two lines down.
private is a suggestion, #private is a lock
public, private, and protected are checked by the compiler and then thrown away. Nothing
stops a JavaScript caller — or a JSON.stringify, or a debugger — from reaching a private
field at runtime. JavaScript's own # fields are enforced by the engine:
class Vault {
private secret = 'compile-time only'
#realSecret = 'runtime too'
reveal(): string {
return `${this.secret} / ${this.#realSecret}`
}
}
const vault = new Vault()
console.log(vault.reveal())Use private when you are drawing a line for your own team, which is most of the time — it reads
better and plays nicely with mocks and tests. Use # when the boundary has to survive contact
with untyped callers, like a library you publish to npm.
protected sits in between: visible to subclasses, hidden from everyone else. It is only useful
if you are actually building an inheritance hierarchy, and most codebases are not.
One consequence of private being compile-time only is worth remembering when you write tests.
A private field is still there at runtime, so (service as any).cache reaches it and the test
passes. That is occasionally the pragmatic escape hatch, and it is also a decent signal that the
thing you are trying to test wants to be a separate, public unit instead.
readonly and getters
readonly stops reassignment after construction. It is shallow — a readonly array field can
still be pushed to — but it catches the common mistake:
class Temperature {
readonly celsius: number
constructor(celsius: number) {
this.celsius = celsius
}
get fahrenheit(): number {
return this.celsius * 1.8 + 32
}
}
const today = new Temperature(21)
console.log(today.fahrenheit)A getter is the right tool when a value is derived rather than stored. Callers read
today.fahrenheit as if it were a field and never have to know it was computed.
static members and typeof MyClass
static members live on the constructor, not on instances. That raises a question people get
stuck on: how do you type "the class itself" rather than "an instance of it"? The answer is
typeof:
class AppLogger {
static instances = 0
constructor(public name: string) {
AppLogger.instances += 1
}
}
// `AppLogger` is the instance type. `typeof AppLogger` is the constructor.
function makeLogger(ctor: typeof AppLogger, name: string): AppLogger {
return new ctor(name)
}
const httpLogger = makeLogger(AppLogger, 'http')A class declaration quietly creates two things with the same name: a type (the instance shape)
and a value (the constructor). typeof is how you ask for the second one.
extends, super, and override
Inheritance works the way it does in JavaScript, with one extra rule: a subclass constructor has to
call super() before it touches this. TypeScript enforces that rather than letting you discover
it at runtime.
class BaseRepo {
constructor(protected table: string) {}
find(id: string): string {
return `select * from ${this.table} where id = ${id}`
}
}
class UserRepo extends BaseRepo {
constructor() {
super('users')
}
override find(id: string): string {
return `${super.find(id)} and deleted_at is null`
}
}protected is what lets UserRepo read this.table while keeping it off the public surface, and
super.find(id) calls the parent implementation rather than recursing.
The override keyword is optional but worth turning on with noImplicitOverride in your
tsconfig.json. Without it, renaming a method on the base class silently orphans the subclass
version: it keeps compiling, it just stops being called. With it, the compiler tells you.
Deep hierarchies age badly, though. Two levels is usually the point where a change to the base class starts breaking descendants you had forgotten about, and composition — holding an instance rather than extending it — gets you the reuse without the coupling.
abstract and implements
abstract marks a class that cannot be instantiated and may declare methods without bodies.
implements checks a class against an interface without affecting what
the class inherits:
interface Drawable {
area(): number
}
abstract class ShapeBase implements Drawable {
abstract area(): number
describe(): string {
return `area ${this.area().toFixed(2)}`
}
}
class CircleShape extends ShapeBase {
constructor(private radius: number) {
super()
}
area(): number {
return Math.PI * this.radius * this.radius
}
}
const shapes: Drawable[] = [new CircleShape(2)]implements is a checked assertion, nothing more. It does not add members, and it does not make
the class assignable to anything it was not already assignable to — TypeScript is structural, so
CircleShape would satisfy Drawable with or without the keyword. The value of writing it is
that the error lands on the class definition instead of at some distant call site.
Generic classes
Type parameters work on classes exactly as they do on functions. If you are comfortable with generics, there is nothing new here:
class TypedStack<T> {
private items: T[] = []
push(item: T): void {
this.items.push(item)
}
pop(): T | undefined {
return this.items.pop()
}
}
const stack = new TypedStack<string>()
stack.push('a')
const topItem = stack.pop()The parameter is bound when you write new TypedStack<string>(), and every method is typed
against it from there. Note that pop returns T | undefined — an empty stack is a real
possibility and the type says so.
You can usually skip the explicit <string>. TypeScript infers the type parameter from the
constructor arguments when there are any, so a class that takes an initial value in its constructor
— say class Box<T> { constructor(public value: T) {} } — lets you write new Box('a') and get
Box<string> for free. TypedStack above declares no constructor, so there is nothing to infer
from and the explicit <string> has to be spelled out.
The this problem
Methods are not bound to their instance. Pull one off an object and pass it somewhere as a
callback and this is gone, which is exactly the situation behind
TS2683: 'this' implicitly has type 'any':
class ClickTracker {
count = 0
// ❌ loses `this` the moment it is passed as a callback
increment(): void {
this.count += 1
}
// ✅ an arrow-function property captures `this` at construction
incrementSafely = (): void => {
this.count += 1
}
}
const tracker = new ClickTracker()
const detached = tracker.increment
// detached() throws at runtime — `this` is undefined
const bound = tracker.incrementSafely
bound()The arrow-function field fixes it at the cost of one closure per instance, which is fine for a
handful of objects and wasteful for thousands. The alternative is to bind at the call site with
() => tracker.increment() and leave the class alone.
When to reach for a class
The honest answer in a TypeScript codebase is: less often than you think. A class earns its place when instances carry mutable state that must stay consistent, when you need many objects sharing one set of methods, or when a framework asks for one.
It is the wrong tool when you are only grouping related data. A type and a function do that with less ceremony, and they narrow better:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius * shape.radius
case 'square':
return shape.side * shape.side
}
}This is the same polymorphism the ShapeBase hierarchy gave you, minus the inheritance. Add a
third variant and the compiler points at every switch that has not handled it — inheritance
gives you no such warning. Data as types, behaviour as functions, is the default that fits the
rest of the language.
Once you are comfortable with what a class exposes, the
ClassPublicKeys challenge is a good next step: it asks you to
extract a class's public property names at the type level, which is a sharp way to find out
whether private really means what you assumed.
Summary
A TypeScript class is a JavaScript class plus compile-time checks. Parameter properties and
readonly are cheap wins. private is a team convention while # is a runtime guarantee.
typeof MyClass types the constructor, implements just checks a shape you already satisfy, and
methods still lose this when detached. Reach for a class when state and behaviour genuinely
belong together — and for everything else, a type and a function will serve you better.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges