Skip to content

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).

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;
}
}

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 it
class B { name?: string; } // admit it may be absent → string | undefined
class 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.

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; // 1
p.label; // error: Property 'label' is private

This 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.

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 only

private 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'
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.

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.

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; // 77

Rules:

  • A getter with no setter produces a readonly property 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); }
}

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 class
new 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 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; }
}
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.

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.

legacy-lib.d.ts
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.

The distinction is mechanical and constantly confuses people:

  • A .d.ts file with no top-level import or export is a global script. Everything it declares is visible project-wide.
  • A .d.ts file with a top-level import or export is a module. Its declarations are scoped, and you must import them.
globals.d.ts — global, no imports/exports
declare const __BUILD_VERSION__: string;
declare function gtag(command: string, ...args: unknown[]): void;
interface Window {
dataLayer: unknown[];
}
api.d.ts — a module, because it exports
export interface User { id: number }
// `declare const __X__` here would NOT be global.

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.

To add to global types from inside a module, wrap the declarations in declare global.

src/env.d.ts
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.

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.

Bundlers let you import CSS, SVGs, and images. TypeScript needs to be told.

src/assets.d.ts
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.css'; // no exports — just make the import legal

Most 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.

tsconfig.json
{
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist"
}
}
  • declaration emits .d.ts alongside .js.
  • declarationMap emits .d.ts.map, so consumers’ “go to definition” lands on your source rather than the declaration.
  • emitDeclarationOnly emits 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.

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.

Terminal window
npm install --save-dev @types/node
npm install --save-dev @types/express
npm install --save-dev @types/lodash

TypeScript automatically includes everything under node_modules/@types — no import needed for the global ones. To restrict that:

tsconfig.json
{
"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:

Terminal window
npm view @types/lodash version # does a @types package exist?
npx tsc --noEmit # errors will name any missing declarations

If none exists and the package is small, a local stub is enough:

src/types/untyped-lib.d.ts
declare module 'untyped-lib' {
const anything: any;
export default anything;
}

That silences the error while being honest that you have no type safety there.

  • strictPropertyInitialization forces fields to be assigned; ! opts out with an assertion.
  • Parameter properties emit real code — they aren’t erasable.
  • private/protected are compile-time only; #field is enforced by the JavaScript engine.
  • Private members make a class nominally typed.
  • implements verifies, it does not infer; structural typing means it’s optional.
  • A .d.ts with no top-level import/export is global; add export {} plus declare global to augment globals from a module.
  • declare module 'pkg' after import 'pkg' augments; without the import it replaces.
  • Publish types with declaration: true and a types-first exports map.
  • Third-party types come from the package itself or @types/*; skipLibCheck keeps their problems out of your build.