Skip to content

3.7.6 — Classes, Decorators & Declaration Files

Three remaining structural features complete the language tour. Class typing — TypeScript's layer over 3.6.4's runtime machinery, with the modifiers Java refugees expect and the erasure caveats they don't. Decorators — the annotation syntax behind Angular and NestJS, finally standardized after a decade of confusion. And declaration files — the .d.ts layer that carries types across package boundaries, which is the answer to "how does npm install know my types?" and the mechanism behind @types, declaration merging, and module augmentation.

1. Classes with types: the full modifier set

typescript
class Account {
  readonly id: string;                    // (1) may only be set in the constructor
  private balance = 0;                    // (2) TS-only privacy — see below
  protected owner: string;                // (3) this class + subclasses
  static nextId = 1;                      // (4) on the constructor (3.6.4)

  constructor(owner: string, public currency: string) {   // (5) PARAMETER PROPERTY:
    this.id = `acc-${Account.nextId++}`;                  //     declares + assigns
    this.owner = owner;                                   //     this.currency in one token
  }

  deposit(amount: number): this {         // (6) `this` return type → chainable,
    this.balance += amount;               //     and subclasses return the SUBCLASS
    return this;
  }
}

(1) readonly on class fields: constructor-set, then immutable at compile time. (2, 3) access modifiers private/protected/public — checked by the compiler, erased at runtime (3.7.1): the property is an ordinary visible key when the code runs. JavaScript's #field (3.6.4) is the runtime-enforced alternative — the comparison table lives there; modern guidance: # when privacy must hold, TS modifiers as team convention within typed code. (5) Parameter propertiespublic/private/readonly on a constructor parameter declares the field and assigns it — beloved shorthand, and one of the few non-erasable emitters (it generates the assignment). (6) The special this type: a fluent API returning this stays correctly typed through inheritance — new SavingsAccount().deposit(5).addInterest() works because deposit returns SavingsAccount, not Account.

Two more relationships complete class typing. implements checks a class against an interface — pure compile-time conformance, adding nothing at runtime (and unlike extends, nothing to instanceof — the interface stays erased). abstract classes sit between interface and base class: they can't be instantiated, may mix implemented methods with abstract ones subclasses must fill, and do exist at runtime (usable with instanceof). Choosing: interface for pure contracts, abstract class when shared implementation rides along (3.5's composition warning applies — shallow hierarchies).

One classic gotcha earns its box: a class declares two things at once — a type (the instance shape, used in annotations: let a: Account) and a value (the constructor function). To pass "the class itself," the type is typeof Account or the constructor signature new (...args: any[]) => Account — the shape factory functions and dependency-injection containers require.

2. Decorators: annotations that run

A decorator is a function applied declaratively to a class or member with @name — metaprogramming as syntax:

typescript
function logged<T extends (...args: any[]) => any>(
  target: T, context: ClassMethodDecoratorContext   // (1) modern (TC39) signature
) {
  return function (this: any, ...args: any[]) {     // (2) return a REPLACEMENT method
    console.log(`${String(context.name)}(${args.join(", ")})`);
    return target.call(this, ...args);              // (3) delegate to the original
  };
}

class Calculator {
  @logged                                           // (4) wraps multiply at class-definition time
  multiply(a: number, b: number) { return a * b; }
}
new Calculator().multiply(3, 4);   // → logs "multiply(3, 4)", returns 12

A method decorator receives the original and may return a replacement — interception at definition time; class decorators can wrap constructors; accessor/field decorators adjust storage. This is the machinery under Angular's @Component, NestJS's @Controller/@Injectable, TypeORM's @Entity — frameworks reading annotations to wire systems.

The history you must know to read real codebases: for a decade TypeScript shipped only experimental decorators (experimentalDecorators: true — a different signature and semantics), and Angular/NestJS/TypeORM built on that dialect plus emitDecoratorMetadata (emitting design-time type info that dependency-injection tools (libraries that build your objects for you and hand each one its dependencies) read back at runtime — a rare erasure exception). The TC39 standard decorators (TypeScript 5+, the syntax above) are the future but are not compatible — the two dialects coexist, selected by tsconfig. New greenfield code: standard decorators, or none; framework code: whatever the framework's dialect is. Honest assessment: decorators shine for cross-cutting concerns declared at the declaration (routing, DI, validation, serialization) and cost you explicitness — logic runs that no call site shows. Use where the framework convention earns it; don't invent decorator DSLs for logic a plain function call states better.

3. Declaration files: types across package boundaries

Compiled packages ship JavaScript — types erased. So how does your editor know express's types? Declaration files: .d.tssignatures only, no implementations — the type layer shipped as a sidecar:

typescript
// math.d.ts — what tsc --declaration emits alongside math.js
export declare function add(a: number, b: number): number;
export declare const VERSION: string;

declare means "this exists at runtime; here is only its type." The resolution chain when you import { add } from "lib": the package's types/exports field in package.json points at its .d.ts → if the package ships none, the compiler looks for @types/lib — the DefinitelyTyped repository, community-maintained declarations for untyped packages (npm i -D @types/express) → failing both, the import is any (or an error under noImplicitAny). For library authors: declaration: true in tsconfig emits your .d.ts automatically — hand-writing them is only for typing existing JS. For app authors, .d.ts files are also where you declare ambient facts: globals a script environment provides (declare const APP_VERSION: string), or modules with no types (declare module "legacy-lib").

Two merging mechanisms make declarations extensible — this is where 3.7.2's "interfaces merge" pays off:

Declaration merging: multiple interface declarations of one name combine members. Module augmentation targets a package's interface from your code:

typescript
// augment-express.d.ts — add your property to Express's Request, project-wide
import "express";
declare module "express-serve-static-core" {
  interface Request {
    user?: { id: string; roles: string[] };   // merged into the library's interface
  }
}

Now req.user type-checks in every handler — the standard pattern for auth middleware, and the same mechanism behind extending Window, ProcessEnv, or a component library's theme type (declare global { interface Window { analytics: … } } for globals). The power tool's edge: augmentations are global, invisible at use sites, and order-independent — keep them in one conventional place (types/ folder, listed in include) so they're discoverable, and prefer explicit types where augmentation isn't structurally required.

4. The expert lens

Erasure divides every class feature into two piles. Checked-only: access modifiers, implements, abstract-ness of methods, generics on classes. Runtime-real: # fields, extends wiring, instanceof, parameter-property assignments, decorators (they run), emitDecoratorMetadata. Every confusing class question — "why can I read a private field via bracket access at runtime?", "why can't instanceof see my interface?" — resolves by asking which pile. Teams that internalize the two piles stop expecting the compiler to police runtime behavior and put runtime enforcement (validation, #, freezing) where it actually holds.

Declaration files are the ecosystem's essential contract. DefinitelyTyped is one of the largest type databases ever assembled, and it's why gradual typing won: the community could type the world's JavaScript without touching it. The .d.ts boundary is also where type-reality drift lives — a wrong declaration is a confident lie to every consumer (3.7.7's boundary discipline applies: the closer types are generated from truth — tsc --declaration, schema codegen — the less room for drift). When debugging "types say X but runtime does Y" in a dependency, read its .d.ts first; you're usually looking at a hand-maintained approximation.

Augmentation is capability, not habit. Extending Request for auth or ProcessEnv for config is idiomatic precisely because the runtime genuinely adds those properties — the augmentation documents a truth. Augmenting to silence errors about properties nothing actually adds is the inverted use: a global lie. The test is always: does the declaration describe reality somewhere, or manufacture agreement?

Next: 3.7.7 closes the folder where every thread has pointed: the runtime boundary, schema validation, migration strategy, and what TypeScript's real product is.

Recall

  • Class modifiers: readonly (constructor-set), private/protected/public (checked, erased# fields are the runtime-real privacy), static, parameter properties (declare+assign in the constructor — a code-emitting feature), this return type (fluent APIs that survive subclassing). A class is type + value; "the class itself" types as typeof C / new () => C.
  • implements = compile-time conformance to an interface (invisible to instanceof); abstract classes = contract + shared implementation, real at runtime.
  • Decorators @name intercept declarations (wrap methods/constructors). Two incompatible dialects: legacy experimentalDecorators (+emitDecoratorMetadata — Angular/NestJS/TypeORM) vs TC39 standard (TS 5+). Use where framework convention earns the implicitness.
  • .d.ts declaration files ship signatures without implementations (declare); resolution: package's own typesDefinitelyTyped @types/*any. Authors: declaration: true emits them. Declaration merging + module augmentation extend library interfaces (Request.user, Window, ProcessEnv) — document runtime truths, never manufacture agreement.

Self-test: Which class features survive to runtime and which are erased — sort the full list. What do parameter properties emit? Why are there two decorator dialects and which do Angular/NestJS use? Walk the type-resolution chain for an npm import. Show the augmentation that types req.user and state the discipline governing it.

Quiz Bank

FoundationalSort TypeScript's class features into compile-time-only vs runtime-real, and explain the practical consequence of each side.

Compile-time only (erased): private/protected/public modifiers, readonly, implements, generic parameters on classes, abstract checking — at runtime the "private" property is an ordinary enumerable key (readable via obj["balance"], present in JSON.stringify), and no interface conformance exists to inspect. Runtime-real: # private fields (syntax-error-enforced, invisible — 3.6.4), extends prototype wiring and instanceof, static properties, parameter-property assignments (emitted code), decorators (functions that execute), and abstract classes as values (they exist; instantiation is only compile-blocked). Consequence: use TS modifiers as team convention and API documentation; use #, Object.freeze, and validation when the guarantee must hold against runtime access — and never expect instanceof to check an interface (schema/discriminant checks do that — 3.7.3).

FoundationalWhat are parameter properties, and why are they notable beyond convenience?

A constructor parameter prefixed with an access modifier or readonlyconstructor(private http: HttpClient, public readonly config: Config)declares the field and assigns it in one token, replacing three lines of boilerplate per dependency; it's the idiom Angular/NestJS constructor injection leans on. Notable because it's one of TypeScript's few non-erasable features (3.7.1): the compiler emits the this.http = http assignments, so the feature generates runtime code — excluded from the "erasable syntax only" subset (and from Node's built-in type stripping). Teams targeting pure-erasure toolchains write the assignments explicitly instead.

AppliedExplain the two decorator dialects and how a framework codebase decides which is in play.

Legacy/experimental (experimentalDecorators: true): the pre-standard design — decorator functions receive (target, propertyKey, descriptor), often paired with emitDecoratorMetadata which emits parameter/return type metadata for runtime reflection — the mechanism NestJS and Angular DI use to know what to inject, and TypeORM to infer column types. TC39 standard (TypeScript 5+, no flag): different signatures ((target, context) with a rich context object), different capabilities (addInitializer, accessor decorators), not compatible with legacy decorator libraries. Deciding factor is tsconfig + framework: experimentalDecorators: true in the config (or an Angular/Nest/TypeORM dependency) ⇒ legacy dialect everywhere; absence of the flag on TS 5+ ⇒ standard. The dialects can't mix in one compilation, so frameworks migrate slowly. Guidance: greenfield non-framework code uses standard decorators sparingly (cross-cutting concerns at declarations) or none; framework code follows the framework, and treats emitDecoratorMetadata's reflection as the erasure exception it is.

InterviewHow does TypeScript find types for an npm package? Walk the full resolution, including when nothing is found.

On import x from "lib": (1) the compiler resolves the JS module (3.6.5, per moduleResolution), then looks for its types via the package's own package.json — the types/typings field or per-entry exports type conditions — pointing at shipped .d.ts files (packages authored in TS emit these with declaration: true). (2) If the package ships none, it checks node_modules/@types/libDefinitelyTyped, the community repo of hand-written declarations installed as @types/* dev-dependencies. (3) Failing both: the import types as any — silently un-typed under loose configs, a compile error under noImplicitAny — which you resolve by installing @types/lib, writing a local ambient declaration (declare module "lib" with real signatures, or the bare form as a typed-as-any escape hatch), or ideally contributing the declarations upstream. Bonus depth: hand-written .d.ts can drift from the JS reality — when types and behavior disagree in a dependency, read its declarations before its docs.

StaffYour platform team wants every service's Express handlers to see req.user and req.traceId with correct types, enforced org-wide. Design the typing architecture and its guardrails.

Mechanism: module augmentation of express-serve-static-core's Request interface, shipped from one place — the platform's middleware package (@org/http-kit) that actually sets those properties at runtime: the augmentation and the code that makes it true travel together, which is the discipline that keeps augmentation honest. The package exports the middleware plus a types.d.ts containing declare module "express-serve-static-core" { interface Request { user?: AuthUser; traceId: string } }, referenced via the package's types field so merely depending on the kit applies the types.

Design decisions: user stays optional — it's absent before the auth middleware runs, and marking it required would let pre-auth code read it confidently (the type would lie); handlers that require auth use a narrowing helper (requireUser(req): AuthUser — an assertion function, 3.7.3) so post-guard code gets the non-optional type honestly. traceId can be non-optional only because the kit installs it first in the chain — document that ordering contract.

Guardrails: services must not write their own Request augmentations (lint rule scanning for declare module "express-serve-static-core" outside the kit — competing augmentations merge silently and drift); the kit versions its augmentation like an API (a field rename is a breaking change across every service); integration tests in the kit assert runtime-sets-what-types-say (set/read through a real Express app); and the kit's docs state the principle for future fields: augment only what this package's runtime provably provides, optional unless middleware-ordering guarantees presence. This is declaration merging used as intended — a typed contract for a runtime truth, centralized so it can't fork.

Flashcards

FlashTS private vs #private

TS modifiers: checked, erased, convention. #fields: runtime-enforced, invisible, brand-checkable. Guarantee needed → #.

FlashParameter properties

Modifier on a constructor param declares + assigns the field. DI idiom; emits code (non-erasable).

FlashClass = type + value

Annotations use the instance type (let a: Account); passing the class itself types as typeof Account / new () => Account.

FlashDecorator dialects

Legacy experimentalDecorators (+emitDecoratorMetadata) — Angular/NestJS/TypeORM. TC39 standard — TS 5+, incompatible signatures. tsconfig decides.

FlashType resolution for imports

Package's own types field → @types/* (DefinitelyTyped) → any (error under noImplicitAny). Authors: declaration: true.

FlashModule augmentation

declare module "lib" { interface X { … } } merges your members into the library's interface (req.user, Window, ProcessEnv). Only declare what runtime truly provides.

Scenario Drill

DrillYou inherit a plain-JavaScript internal library (12 exported functions, 2 classes) used by six TypeScript services, all currently importing it as any. Shipping types for it must not require rewriting it in TS this quarter. Lay out the options, pick one, and execute the risky parts.

Options ladder: (1) Hand-written .d.ts shipped in the package — fastest, no code changes: add types: "./index.d.ts" to package.json, write declarations for the 14 exports. Risk: drift — the declarations are an unverified parallel description. (2) JSDoc + checkJs — annotate the JS with JSDoc types and let tsc check the implementation against them, emitting .d.ts via declaration: true + allowJs: more work than (1), but the types are verified against the real code — drift becomes a compile error in the library's own CI. (3)

Full TS rewrite — ruled out this quarter. Pick (2) for the steady state, with (1) as the week-one stopgap if consumers are blocked today. Executing the risky parts: the two classes need care — declare constructor signatures, distinguish instance type vs typeof class for any factory usage, and check for prototype patterns JSDoc struggles with (dynamic method assignment — 3.6.4) which may deserve a small refactor to class syntax (behavior-identical, sugar only). Any function taking "options bags" gets a named @typedef — the union/optional honesty matters more than coverage (a named Options typedef with retries?: number and signal?: AbortSignal fields, referenced from @param). Where the library genuinely returns dynamic shapes, declare unknown and let consumers validate (3.7.7) rather than lying with specific types.

Verification loop: a tests/types.test-d.ts in the library using tsd/@ts-expect-error fixtures asserts the public surface (right types accepted, wrong types rejected); the six services bump the version behind a single integration branch and their tsc --noEmit runs become the cross-repo contract test — any place the new types disagree with actual usage surfaces now, as errors to adjudicate (fix the type or fix the caller) instead of later as runtime surprises. Principle: types for existing JS should be generated or checked against the implementation whenever possible — a hand-written declaration is a promise with no witness.