Chapter 12 of 24 · TypeScript for Beginners — Complete Free Tutorial
Classes and Access Modifiers
JavaScript has had classes since 2015, and you may know them from the this keyword. In the TypeScript for Beginners series they arrive with a compiler attached: declared properties, access modifiers, abstract contracts and implements checks. This chapter of the 24-chapter series covers the class syntax you will read in every framework codebase, and how classes fit TypeScript's structural view of types.
Classes with typed properties
A TypeScript class declares its properties up front, which is syntax plain JavaScript does not have. The declaration gives every field a type the compiler can check:
class Account {
owner: string;
balance: number = 0;
constructor(owner: string) {
this.owner = owner;
}
deposit(amount: number): void {
this.balance += amount;
}
}
const ada = new Account('Ada');
ada.deposit(150);
console.log(`${ada.owner} has ${ada.balance}`);
Expected output:
Ada has 150
Initialisers run before the constructor body, and under strict mode every property must be initialised somewhere: with an initialiser, in the constructor, or with a definite-assignment assertion (owner!: string;). Otherwise you get Property 'owner' has no initializer and is not definitely assigned in the constructor. (TS2564) — the compiler refusing to let a field be undefined by accident.
Access modifiers, readonly and parameter properties
Four modifiers shape a class's surface. public is the default; private confines a member to the class; protected allows the class and its subclasses; readonly forbids reassignment after initialisation. Declaring a field and then assigning it in the constructor is such a common rhythm that TypeScript compresses it: put an access modifier on a constructor parameter and it becomes a property, assigned for you — a parameter property:
class Session {
readonly createdAt: Date = new Date();
constructor(
public readonly id: string,
private token: string,
protected refresh = '',
) {}
describe(): string {
return `${this.id} (${this.token.length} chars)`;
}
}
console.log(new Session('s_1', 'secret').describe());
Expected output:
s_1 (6 chars)
The long form this replaces is public readonly id: string; declared at the top plus this.id = id; in the constructor — the right choice when the constructor does extra work per field. Modifiers combine freely, so private readonly is a common pairing. The errors are the useful part: reading session.token from outside fails with Property 'token' is private and only accessible within class 'Session' (TS2341); reading session.refresh from an unrelated class fails with Property 'refresh' is protected and only accessible within class 'Session' and its subclasses (TS2445); assigning session.createdAt = new Date() fails with Cannot assign to 'createdAt' because it is a read-only property (TS2540).
Two honest caveats. These modifiers are compile-time only — the emitted JavaScript has ordinary properties, so anything that matters at runtime deserves a real JavaScript private field (#token) instead, and readonly is shallow: the reference cannot be reassigned, but the object it points at is still mutable. Parameter properties are a shortcut rather than a new runtime feature, which is exactly why the compiler's erasableSyntaxOnly option (TypeScript 5.8 and later) rejects them with TS1294 alongside enum.
Abstract classes and implements
abstract marks a class as a template that cannot be instantiated itself. Abstract members declare a contract with no body, and every concrete subclass must provide one:
abstract class Shape {
abstract area(): number;
describe(): string {
return `area: ${this.area().toFixed(2)}`;
}
}
interface Describable {
describe(): string;
}
class Circle extends Shape implements Describable {
constructor(private radius: number) { super(); }
area(): number {
return Math.PI * this.radius ** 2;
}
}
console.log(new Circle(1).describe());
Expected output:
area: 3.14
Trying new Shape() stops you with Cannot create an instance of an abstract class. (TS2511), and a subclass that forgets area is rejected. implements is the other half of the contract: it checks that a class carries a set of members without giving you any code to inherit, you may implement several interfaces at once, and Circle above satisfies Describable because it inherits a describe method. Miss a member and you get Class 'Broken' incorrectly implements interface 'Describable' naming what is absent (TS2420). Remember the split: extends inherits behaviour, implements only demands shape.
Static members, getters and setters
static members belong to the class itself rather than to instances, and accessors let you expose a value as a property while hiding how it is computed:
class Counter {
static created = 0;
private sum = 0;
constructor() {
Counter.created += 1;
}
get total(): number {
return this.sum;
}
set total(value: number) {
this.sum = value;
}
}
const c1 = new Counter();
const c2 = new Counter();
c1.total = 10;
c2.total = 1;
console.log(c1.total, c2.total, Counter.created);
Expected output:
10 1 2
Notice three things: Counter.created is shared by every instance (inside a static member, this refers to the class), total is read and written without parentheses because getters and setters are properties, and c1.total = 10 silently ran your setter. A getter and setter for the same name must have the same visibility.
Structural typing and declaration merging
TypeScript classes are still structural: what matters is the shape, not where it came from. Any object with the right members satisfies the requirement, class instance or not:
interface Point {
x: number;
y: number;
}
function show(point: Point): string {
return `${point.x},${point.y}`;
}
console.log(show({ x: 1, y: 2 }));
const point3d = { x: 1, y: 2, z: 3 };
console.log(show(point3d));
Expected output:
1,2
1,2
The second value carries an extra z and is still accepted, because extra members never hurt a variable — only a fresh object literal written inline is checked for unknown properties (a Point3D class instance works for the same reason). The one exception is private: a class with private members only accepts its own kind, so const p: Point3D = { x: 1, y: 2, z: 3 } fails with Property 'x' is private in type 'Point3D' but not in type '{ x: number; y: number; z: number; }'. That is how a structural language gets nominal behaviour exactly where you asked for encapsulation.
Finally, interfaces and classes merge: an interface Course declared alongside class Course adds slug to the class's instance type, so a variable created with new Course('TypeScript') may be given a slug property without complaint. The interface contributes no code — you must still provide slug at runtime — but the compiler now expects it, which is how declaration files augment existing classes (chapter 17 picks that up).
Common mistakes
- Expecting access modifiers at runtime —
privateis erased when compiled; use#fieldfor real JavaScript privacy. - Forgetting
this.— writingowner = ownerin the constructor shadows the parameter and leaves the property undefined. - Leaving a property uninitialised — strict mode reports TS2564; assign it, initialise it, or assert it with
!deliberately. - Confusing
implementswithextends—implementschecks shape only; no method bodies are inherited. - Assuming
readonlydeep-freezes — it blocks reassignment of the reference, not mutation of the object behind it.
Practice checklist
- Rewrite your last plain-JavaScript class with declared properties and read the strict-mode errors it reports.
- Convert one constructor into parameter properties, then back again, and diff the two forms.
- Build an
abstractbase class with two concrete subclasses and trigger the TS2511 error on purpose. - Implement an interface with three members and delete one to see TS2420.
- Add a static counter and a getter to a class and predict the output before you run it.
What's next?
Classes give you shape and behaviour — next, the standard toolbox of type transformations: Utility Types.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.