skip to content

In TypeScript, how do you add a property to an interface that an npm package exports, and what rules must the `declare module 'pkg'` block obey?

level: middleimportance: should knowfreq 48%

answer

  1. merging aimed at another declaration space
  2. the host file must be a module
  3. the specifier has to resolve
  4. patches only, no new top-level exports
  5. default exports have no name to merge

basics

~20 s

Write a module augmentation: in a file that is itself a module, a declare module 'pkg' block re-declares one of the package's exported interfaces, and the members merge into the original. It can only patch existing declarations.

solid answer

~50 s

Module augmentation is declaration merging aimed at another module's declaration space. In a file that is a module — it has a top-level `import` or `export` — you write `declare module 'pkg'` and inside it re-declare an interface the package exports, adding your members; the compiler merges them with the original, so every file that imports that interface sees the extra members. Three rules bite in practice. The module specifier must be the one that resolves to the declaration you are targeting, not a convenient alias. You may only patch existing declarations, so you cannot introduce brand-new top-level exports from the augmentation. And a default export cannot be augmented — only named ones, since you augment by exported name. Finally the file has to be part of the compilation; an augmentation nobody pulls in does nothing.

go deeper

for a junior

Recall the shape: a declare module 'pkg' block, inside a file that is a module, re-declaring an exported interface to add members.

for a middle

Explain that this is interface merging targeted at another module's scope, and state the rules — resolvable specifier, patches only, no default exports, and the file must be in the program.

for a senior

Diagnose the silent failures — file not in the compilation, wrong entry-point specifier, target is a closed alias — and keep added fields optional so the type does not promise what no code enforces.

for a principal

Weigh the blast radius: an augmentation is program-wide and unconditional, collides hard when two packages add the same member, and travels to consumers if you publish it. Decide when a local wrapper type is the sounder contract.

## What an augmentation actually is A module augmentation is nothing more exotic than interface merging pointed at somebody else's declaration space. Ordinarily, declarations merge only within one scope, and a module's declarations are sealed inside that module. `declare module '<specifier>'` opens that scope by name so your declarations land inside it: ```ts import { Session } from 'session-lib'; declare module 'session-lib' { interface Session { tenantId: string; } } function tenantOf(s: Session): string { return s.tenantId; // now type-checks } ``` The package's own `Session` interface and yours merge into one, and the merged shape is what *every* consumer in the compilation sees — not only this file. ## The file must be a module The containing file needs a top-level `import` or `export`; add `export {}` if it has neither. This is not ceremony. In a script file, `declare module 'session-lib' { ... }` means something entirely different: it declares an *ambient module* — a from-scratch description of a module that has no types of its own. Same syntax, opposite meaning. In a module file it augments; in a script file it declares. Getting this wrong is the classic failure where the package's real types appear to vanish or the additions appear to be ignored. ## The specifier must be the one that resolves You augment the module whose specifier you write, and it must resolve to the declaration you are targeting. If the interface you want lives in a sub-package or an internal entry point, the augmentation must name *that* specifier, not the umbrella one you happen to import from in application code. If the specifier does not resolve at all, the compiler reports an invalid module name in the augmentation. When an augmentation "does nothing", a mismatched specifier is the first thing to check, and reading the package's own type declarations to find where the interface is really declared is the way to settle it. ## What you may write inside Two restrictions come from the design: - **Only patches to existing declarations.** You cannot add new top-level declarations to the module through an augmentation. Adding members to an interface the module already exports is a patch; conjuring a brand-new exported entity is not. - **No default exports.** Augmentation works by exported name, and there is no name to merge against for a default export. If a library wants to be augmentable, it must expose named declarations. Beyond that, you are bound by ordinary merging rules. You can add members to an exported `interface`; you can add members to an exported `namespace`; and because an interface declaration merges with a class's instance type, you can add instance members to an exported class. You cannot merge into an exported `type` alias at all — aliases are closed, which is exactly why library authors who want extensibility publish interfaces. And you cannot change the type of a member that already exists: same-name, different-type is a merge conflict error, not an override. ## The file has to be in the program An augmentation only exists if its file is part of the compilation. A file that nothing imports and that the program does not otherwise include is invisible, and the additions silently do not apply. The two usual remedies are to import the augmentation file from somewhere in the app, or to keep it in a location the project already includes. (The mechanics of *which* files a project pulls in are a compiler-configuration topic in their own right; the point here is that an augmentation is not magically discovered.) ## Design consequences worth saying out loud Augmentation is global and unconditional. There is no way to scope it to one folder, one team, or one entry point: once the file is in the program, everyone's `Session` has `tenantId`, including code that has never heard of your feature. That is powerful for genuine cross-cutting extensions — a plugin ecosystem whose whole design is "packages contribute fields" — and hazardous when two packages augment the same interface with the same name and different types, which is a hard error somebody has to resolve. It is also, as always in TypeScript, only a claim. The types are erased, so declaring `tenantId: string` does not make the field exist; if the middleware that sets it did not run, you read `undefined` from a value the compiler swears is a `string`. Declaring such fields optional keeps the type honest and forces call sites to handle the case where the extension has not been applied. ## When to reach for something else If only your own code needs the extra field, a local type — an intersection at the call site, a wrapper, or a narrow accessor that returns the enriched shape — keeps the change visible and reversible. Reserve augmentation for cases where third-party code, or code you do not control, must see the enriched type.

  • Your augmentation compiles but the added property is still missing at the call site. What do you check first?
    Three things, in order. Is the augmentation file actually part of the compilation — does anything import it, or is it otherwise included? Does the specifier you wrote resolve to the same declaration your call site imports, rather than a re-export or a different entry point? And is the target really an interface (or namespace, or class) rather than a closed `type` alias, which cannot be merged into at all.
  • Why does the same `declare module 'pkg'` block behave differently in a file with no imports or exports?
    Because that file is a script, not a module, and there the block declares an ambient module from scratch instead of augmenting an existing one. The syntax is identical and the meaning is opposite. Adding `export {}` at the top makes the file a module and turns the block back into an augmentation.
  • A library exports its main type as `export type Options = { ... }`. Can you augment it?
    No. Type aliases are closed — they cannot be reopened in their own module or from anywhere else, so there is nothing to merge into. Your options are to compose locally, for example an intersection `Options & { retries: number }` in your own code, or to ask the library to publish an interface, which is precisely why extensible library surfaces are declared as interfaces.

saying these in an interview costs you the question

  • Writes the augmentation in a file with no import or export
  • Expects to add brand-new exports to the module via augmentation
  • Tries to augment a default export by name
  • Assumes any specifier that looks right will resolve
  • Believes the declared field is guaranteed to exist at runtime

context