Classes and Declarations
Two topics that sit at the boundary between TypeScript’s type system and real JavaScript: classes (where TypeScript adds syntax that JavaScript doesn’t have) and declaration files (where you describe JavaScript that TypeScript can’t see).
Typed classes
Section titled “Typed classes”Class fields need types, and under strict they need to be definitely assigned.
class Account { id: string; balance: number; createdAt = new Date(); // type inferred from the initialiser
constructor(id: string, balance: number) { this.id = id; this.balance = balance; }
deposit(amount: number): void { this.balance += amount; }}strictPropertyInitialization
Section titled “strictPropertyInitialization”Part of strict. Every declared field must be assigned in its declaration or in the constructor.
class Bad { name: string; // error: Property 'name' has no initializer and is not // definitely assigned in the constructor.}Three legitimate fixes:
class A { name: string = ''; } // initialise itclass B { name?: string; } // admit it may be absent → string | undefinedclass C { name!: string; } // definite assignment assertion: "trust me"The ! form is for fields assigned by a framework, a DI container, or an init() method. It is an
assertion — nothing verifies it.
Parameter properties
Section titled “Parameter properties”A shorthand that declares and assigns a field from a constructor parameter. It requires an access
modifier or readonly.
class Point { constructor( public readonly x: number, public readonly y: number, private label = 'unnamed', ) {}}
const p = new Point(1, 2);p.x; // 1p.label; // error: Property 'label' is privateThis is one of the few TypeScript features that emits code — the compiler generates
this.x = x; in the constructor body. Tools that only strip types (Node’s type stripping, tsx
in strip mode) reject it, and erasableSyntaxOnly flags it.
Access modifiers
Section titled “Access modifiers”| Modifier | Visible in the class | Subclasses | Outside | Enforced at runtime |
|---|---|---|---|---|
public (default) |
Yes | Yes | Yes | n/a |
protected |
Yes | Yes | No | No |
private |
Yes | No | No | No |
#field (JS private) |
Yes | No | No | Yes |
class Base { public name = 'base'; protected internalId = 0; private secret = 'hidden'; #reallyPrivate = 'enforced';}
class Child extends Base { show() { this.name; // fine this.internalId; // fine this.secret; // error: Property 'secret' is private and only accessible within 'Base' }}
const b = new Base();(b as any).secret; // 'hidden' — TypeScript's private is compile-time onlyprivate and protected also make classes nominally typed: two structurally identical classes
are incompatible if either has private members declared separately.
class A { private x = 1 }class B { private x = 1 }let a: A = new B(); // error: Types have separate declarations of a private property 'x'readonly fields
Section titled “readonly fields”class Config { readonly apiUrl: string;
constructor(url: string) { this.apiUrl = url; // assignment in the constructor is allowed }
change() { this.apiUrl = 'x'; // error: Cannot assign to 'apiUrl' because it is read-only }}Compile-time only, like private. Object.freeze is the runtime equivalent.
static members
Section titled “static members”class Counter { static instances = 0; static readonly MAX = 100;
static reset(): void { Counter.instances = 0; }
constructor() { Counter.instances++; }}Static blocks (static { ... }) are supported for complex initialisation. Static members cannot
reference the class’s generic type parameters.
Getters and setters
Section titled “Getters and setters”class Temperature { #celsius = 0;
get celsius(): number { return this.#celsius; }
set celsius(value: number) { if (Number.isNaN(value)) throw new RangeError('NaN'); this.#celsius = value; }
get fahrenheit(): number { return this.#celsius * 9 / 5 + 32; }}
const t = new Temperature();t.celsius = 25;t.fahrenheit; // 77Rules:
- A getter with no setter produces a
readonlyproperty from the outside. - Since TypeScript 4.3, the setter’s parameter type may be wider than the getter’s return type — useful for “accepts a string or Date, always returns a Date” properties.
class Model { #date = new Date(); get createdAt(): Date { return this.#date; } set createdAt(value: Date | string) { this.#date = new Date(value); }}abstract classes
Section titled “abstract classes”An abstract class can’t be instantiated and may declare members without implementations.
abstract class Shape { abstract area(): number; abstract readonly name: string;
describe(): string { return `${this.name} with area ${this.area().toFixed(2)}`; }}
class Circle extends Shape { readonly name = 'circle'; constructor(private radius: number) { super(); } area(): number { return Math.PI * this.radius ** 2; }}
new Shape(); // error: Cannot create an instance of an abstract classnew Circle(2).describe(); // 'circle with area 12.57'To accept “a constructor for a subclass” as a value, use an abstract construct signature:
function create(Ctor: new () => Shape): Shape { return new Ctor(); }// `abstract new () => Shape` accepts abstract class references too.implements
Section titled “implements”implements checks a class against an interface. It is a check only — it adds nothing.
interface Serialisable { serialise(): string;}
interface Comparable<T> { compareTo(other: T): number;}
class Money implements Serialisable, Comparable<Money> { constructor(private cents: number) {} serialise(): string { return String(this.cents); } compareTo(other: Money): number { return this.cents - other.cents; }}override and noImplicitOverride
Section titled “override and noImplicitOverride”class Base { greet(): string { return 'hi'; }}
class Child extends Base { override greet(): string { return 'hello'; } override gret(): string { return 'x'; } // error: This member cannot have an 'override' modifier because it is not // declared in the base class 'Base'.}Enable noImplicitOverride in tsconfig to require the keyword on every override. It catches the
opposite failure too — renaming a base method silently orphaning an override.
Declaration files
Section titled “Declaration files”A .d.ts file contains types only, no implementations. It tells TypeScript the shape of
JavaScript it cannot analyse: a plain-JS library, a global script, a bundler-injected value.
declare module 'legacy-lib' { export interface Options { retries?: number; } export function connect(url: string, options?: Options): Promise<void>; export const version: string;}declare means “this exists somewhere; don’t emit anything for it”. Everything in a .d.ts is
implicitly ambient, so the keyword is often redundant inside one — but required in .ts files.
Global vs module declaration files
Section titled “Global vs module declaration files”The distinction is mechanical and constantly confuses people:
- A
.d.tsfile with no top-levelimportorexportis a global script. Everything it declares is visible project-wide. - A
.d.tsfile with a top-levelimportorexportis a module. Its declarations are scoped, and you must import them.
declare const __BUILD_VERSION__: string;declare function gtag(command: string, ...args: unknown[]): void;
interface Window { dataLayer: unknown[];}export interface User { id: number }// `declare const __X__` here would NOT be global.Ambient declarations in .ts files
Section titled “Ambient declarations in .ts files”Use declare when a value is injected by the environment.
declare const __DEV__: boolean;
if (__DEV__) { console.debug('dev build');}Nothing is emitted for the declaration; you’re promising the bundler or runtime supplies it.
Global augmentation
Section titled “Global augmentation”To add to global types from inside a module, wrap the declarations in declare global.
export {}; // makes this file a module
declare global { interface Window { myWidget: { open(): void }; }
// Node.js process.env, typed. namespace NodeJS { interface ProcessEnv { DATABASE_URL: string; NODE_ENV: 'development' | 'production' | 'test'; } }}Now window.myWidget.open() and process.env.DATABASE_URL type-check everywhere. This works
because interfaces support declaration merging.
Module augmentation
Section titled “Module augmentation”Same idea, aimed at someone else’s module.
import 'express';
declare module 'express' { interface Request { user?: { id: string; roles: string[] }; }}The import before the declare module matters: it makes this an augmentation of the existing
module rather than a fresh ambient declaration that replaces it.
Declaring non-code imports
Section titled “Declaring non-code imports”Bundlers let you import CSS, SVGs, and images. TypeScript needs to be told.
declare module '*.svg' { const content: string; export default content;}
declare module '*.css'; // no exports — just make the import legalMost frameworks ship these already (Vite, for example, via its own .d.ts), so check before
writing your own.
Emitting declarations for your own library
Section titled “Emitting declarations for your own library”If you publish a package, generate .d.ts files instead of hand-writing them.
{ "compilerOptions": { "declaration": true, "declarationMap": true, "outDir": "dist" }}declarationemits.d.tsalongside.js.declarationMapemits.d.ts.map, so consumers’ “go to definition” lands on your source rather than the declaration.emitDeclarationOnlyemits types only — useful when a bundler produces the JavaScript.
Point consumers at them in package.json:
{ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }}isolatedDeclarations (TypeScript 5.5+) requires explicit type annotations on all exports so that
declaration files can be generated per-file without full type checking — a build-speed option for
large libraries.
@types and DefinitelyTyped
Section titled “@types and DefinitelyTyped”Many packages ship their own types (check for a types field in their package.json or a bundled
index.d.ts). Those that don’t often have community-maintained types on
DefinitelyTyped, published under the @types scope.
npm install --save-dev @types/nodenpm install --save-dev @types/expressnpm install --save-dev @types/lodashTypeScript automatically includes everything under node_modules/@types — no import needed for the
global ones. To restrict that:
{ "compilerOptions": { "types": ["node", "vitest/globals"] }}An empty "types": [] disables automatic inclusion entirely, which is occasionally necessary when
two global type packages conflict (the classic case: @types/node and @types/jest both declaring
timers, or DOM libs clashing with Node globals).
Finding types for a package:
npm view @types/lodash version # does a @types package exist?npx tsc --noEmit # errors will name any missing declarationsIf none exists and the package is small, a local stub is enough:
declare module 'untyped-lib' { const anything: any; export default anything;}That silences the error while being honest that you have no type safety there.
Key points
Section titled “Key points”strictPropertyInitializationforces fields to be assigned;!opts out with an assertion.- Parameter properties emit real code — they aren’t erasable.
private/protectedare compile-time only;#fieldis enforced by the JavaScript engine.- Private members make a class nominally typed.
implementsverifies, it does not infer; structural typing means it’s optional.- A
.d.tswith no top-level import/export is global; addexport {}plusdeclare globalto augment globals from a module. declare module 'pkg'afterimport 'pkg'augments; without the import it replaces.- Publish types with
declaration: trueand atypes-firstexportsmap. - Third-party types come from the package itself or
@types/*;skipLibCheckkeeps their problems out of your build.