Lesson 20 / 23
Declaration Files
Carrying type definitions separately from implementation; generating declarations from source, ambient module declarations, global scope declarations, and extending outside types with merging.
Contents
The previous lesson made it possible to check the project’s own files. Code coming from outside — a library with no type information, the global objects the runtime provides — is still out of scope.
A declaration file carries only type information and produces no code. Its
extension is .d.ts. It is used in two directions: exposing your own code’s types,
and bringing in outside code’s types.
Generating a Declaration From Source
A library package publishes its implementation as JavaScript. For TypeScript-using consumers to have access to type information, declaration files have to be published too. The compiler generates these from source:
{ "compilerOptions": { "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "declaration": true, "rootDir": "src", "outDir": "dist" }, "include": ["src"] }
Let there be a package.json at the project root with content
{ "name": "measurement-library", "type": "module", "version": "1.0.0" }, and let
src/measurement.ts be this:
export type Unit = "C" | "Pa" | "%"; export interface Measurement { readonly id: string; value: number; unit: Unit; } export function average(records: readonly Measurement[]): number { if (records.length === 0) { return 0; } return records.reduce((t, r) => t + r.value, 0) / records.length; } export const defaultUnit = "C";
Running tsc in the project directory produces two files. dist/measurement.js:
export function average(records) { if (records.length === 0) { return 0; } return records.reduce((t, r) => t + r.value, 0) / records.length; } export const defaultUnit = "C";
And dist/measurement.d.ts:
export type Unit = "C" | "Pa" | "%"; export interface Measurement { readonly id: string; value: number; unit: Unit; } export declare function average(records: readonly Measurement[]): number; export declare const defaultUnit = "C";
The two complete each other. The JavaScript file has no types, it has the
implementation; the declaration file has no implementation, it has the types. The word
declare says exactly this: “this name exists, this is its type, its definition is
not here.”
This split is the package-level counterpart of the type erasure principle from the first lesson. The type layer is separated from the published code; to a consumer not reading it, the package is an ordinary JavaScript package.
The rootDir setting in this lesson has to be written explicitly together with
outDir in this compiler version; when it is not, a TS5011 diagnostic appears. The
setting’s role is determining the output directory layout: src/measurement.ts
becomes dist/measurement.js, not dist/src/measurement.js.
Ambient Module Declaration
When a library carrying no type information is used, its types can be written in the project. This is called an ambient declaration — declaring the type of something whose implementation is elsewhere.
Let a types directory be added to the configuration:
{ "compilerOptions": { "target": "es2022", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "noEmit": true }, "include": ["src", "types"] }
The types/measurement-driver.d.ts file declares the names the library offers:
declare module "measurement-driver" { export interface DriverSettings { address: string; timeout?: number; } export function connect(settings: DriverSettings): Promise<void>; export function read(channel: string): Promise<number>; }
After this declaration, the module can be used in a typed way inside
src/app.ts:
import { connect, read } from "measurement-driver"; export async function firstReading(): Promise<number> { await connect({ address: "10.0.0.7:9000" }); return read("temperature.C"); } export async function invalidCall(): Promise<number> { await connect({ address: "10.0.0.7:9000", retryCoun: 3 }); return read(42); }
src/app.ts(9,45): error TS2353: Object literal may only specify known properties, and 'retryCoun' does not exist in type 'DriverSettings'. src/app.ts(10,15): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
A hand-written declaration is a real contract for the compiler, and all checks apply.
A warning is needed: this declaration’s correctness is not verified. If the library
actually behaves differently, the compiler cannot know. An ambient declaration is a
debt like the as notation and a type predicate; it has to be updated together
when the library’s version changes. This is why a hand-written declaration is not
preferred in cases where the library publishes its own declarations.
Global Scope Declaration
Some names do not come from a module; they are placed into global scope by the
runtime. A declare global block declares these:
declare global { interface MeasurementEnvironment { readonly nodeName: string; readonly samplingRate: number; } const measurementEnvironment: MeasurementEnvironment; } export {};
The trailing export {}; line is required: it makes the file count as a module, and a
declare global block can only be written in module files.
After the declaration, the name can be used without an import:
export function environmentSummary(): string { return `${measurementEnvironment.nodeName} @ ${measurementEnvironment.samplingRate}Hz`; } export function invalidAccess(): string { return measurementEnvironment.unknownField; }
src/environment.ts(6,33): error TS2339: Property 'unknownField' does not exist on type 'MeasurementEnvironment'.
A global scope declaration is a powerful but carefully used tool: the declared name becomes visible in every file of the project, and where it comes from cannot be understood just by reading the code. If a name does not genuinely need to be global, exporting it from a module is preferred.
Extending Outside Types
Declaration merging, introduced in the Interfaces lesson, finds its real use here. A
library’s type can be extended without touching the library. The
types/extension.d.ts file opens a second block with the same module name:
declare module "measurement-driver" { interface DriverSettings { retryCount?: number; } }
Ambient declarations carrying the same module name merge; since DriverSettings is
also an interface, it is open to merging too. As a result, the field becomes usable
without the original declaration changing at all:
import { connect, read } from "measurement-driver"; export async function firstReading(): Promise<number> { await connect({ address: "10.0.0.7:9000", retryCount: 3 }); return read("temperature.C"); } export function environmentSummary(): string { return `${measurementEnvironment.nodeName} @ ${measurementEnvironment.samplingRate}Hz`; }
This file checks without error alongside the previous three declaration files.
That merging is really being applied is seen when the field name is misspelled. When
retryCoun is written instead of retryCount in the same file:
src/app.ts(4,45): error TS2561: Object literal may only specify known properties, but 'retryCoun' does not exist in type 'DriverSettings'. Did you mean to write 'retryCount'?
When the extension file is removed from the project, the diagnostic for the same line changes:
src/app.ts(4,45): error TS2353: Object literal may only specify known properties, and 'retryCoun' does not exist in type 'DriverSettings'.
The difference between the two diagnostics — one has a suggestion, the other does not — directly shows whether merging was applied.
The limit of extension is that it only works on interfaces. If a library defines
its type with type, it cannot be extended; the TS2300 diagnostic from the Type
Aliases lesson appears. The practical consequence for library authors: if the shapes
of exposed objects are declared with an interface, consumers can extend them.
Summary
- A declaration file carries only types; the word
declaresays a name exists but its definition is elsewhere. - The
declarationoption generates declaration files from source; in this compiler version,rootDirhas to be written together withoutDir. - An ambient module declaration types a library with no type information; its correctness is not verified and it is a debt that has to be updated with the library.
- A
declare globalblock declares names in global scope; the file needs anexport {};line to count as a module. - A
declare moduleblock carrying animportline extends an existing module’s types; extension only works on interfaces.
Next Step
An ambient declaration was the way to type an untyped library from outside. What about the project’s own JavaScript files? An existing codebase cannot be converted to TypeScript overnight; this migration has to be done gradually. The next lesson covers checking JavaScript files, typing them with comments, and the migration strategy.
To keep your progress and take notes, Log in
My notes
Log in to take notes.