Skip to content

Volume ITechnology

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 properties — public/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 need in order to read real codebases:

  • For ten years, TypeScript only had experimental decorators, switched on with experimentalDecorators: true. They have a different signature and different behaviour.
  • Angular, NestJS and TypeORM were built on that version, plus emitDecoratorMetadata. That setting writes type information into the output, which dependency-injection tools read back at runtime. (Dependency-injection tools are libraries that build your objects for you and hand each one what it needs.) It is a rare exception to types being erased.
  • The TC39 standard decorators, in TypeScript 5 and later, use the syntax above. They are the future, but they are not compatible with the old ones. Both versions exist side by side, and tsconfig picks which one you get.

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.ts — signatures 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, and here is only its type". When you write import { add } from "lib", the compiler looks for types in this order:

  1. The types or exports field in the package's package.json, pointing at its .d.ts file.
  2. If the package has none, @types/lib. That comes from DefinitelyTyped, a community collection of type declarations for packages that ship without types. You install one with npm i -D @types/express.
  3. If both are missing, the import is typed as any, or it is 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 types → DefinitelyTyped @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.

Only at compile time, then erased:

  • the private, protected and public modifiers;
  • readonly;
  • implements;
  • generic parameters on classes;
  • the checking of abstract.

At runtime, a "private" property is just an ordinary property. You can read it with obj["balance"], and it shows up in JSON.stringify. And there is no record of which interfaces a class implements.

Really there at runtime:

  • # private fields, which the language itself hides and enforces (3.6.4);
  • extends, which wires up prototypes, and instanceof;
  • static properties;
  • parameter properties, which produce real assignment code;
  • decorators, which are functions that run;
  • abstract classes as values. They exist. Only creating one is blocked, and only at compile time.

So treat TypeScript's modifiers as a team convention and as documentation. When a guarantee must hold even against code poking at runtime, use #, Object.freeze and validation. And never expect instanceof to check an interface. Schema checks or 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 readonly — constructor(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 finds the JavaScript module (3.6.5), following moduleResolution. Then it looks for its types in the package's own package.json: the types or typings field, or type conditions inside exports. These point at the .d.ts files the package ships. Packages written in TypeScript produce those files with declaration: true.
  2. If the package ships none, the compiler checks node_modules/@types/lib. That is DefinitelyTyped, the community collection of hand-written declarations, installed as @types/* dev-dependencies.
  3. If both are missing, the import is typed as any. Under loose settings that happens silently. Under noImplicitAny it is a compile error. You fix it by installing @types/lib, by writing a local declaration (declare module "lib" with real signatures, or the bare form as an any escape hatch), or best of all by sending the declarations to the library itself.

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 add their own fields to Request. A lint rule looks for declare module "express-serve-static-core" anywhere outside the shared kit. Competing additions merge without any warning and then drift apart.
  • The kit versions its additions like an API. Renaming a field is a breaking change for every service.
  • The kit's docs state the rule for any future field: only add a field that this package's own code really sets. Make it optional, unless the middleware order guarantees it is always there.

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.

Checking it: the six services move to the new version together on one integration branch, and each runs tsc --noEmit. Any place where the new types disagree with how the code really uses them shows up now, as an error someone must decide on: fix the type or fix the caller. That is far better than a surprise at runtime later. The principle: types for existing JavaScript should be generated from the code, or checked against it, whenever possible. A hand-written declaration is a promise with nobody checking it.