Declaration files and third-party types
Writing .d.ts declarations, using @types packages, typing an untyped dependency locally, and publishing types with your own package.
Writing a declaration file
// src/types/user.d.ts - types only, no implementation, no JavaScript emitted
export interface User {
id: string;
name: string;
}
export declare function loadUser(id: string): Promise<User>;
export declare const version: string;
// declare module gives a package a shape when it ships none
declare module 'legacy-chart' {
export interface Options {
width: number;
height: number;
}
export function render(target: string, options: Options): void;
const chart: { render: typeof render };
export default chart;
}
// ambient globals: something another script puts on window
declare global {
interface Window {
dataLayer: unknown[];
}
}
export {}; // makes the file a module, which declare global requiresdeclareasserts that something exists elsewhere and emits nothing, so nothing verifies that it matches reality. An incorrect declaration is worse than none.- Only shapes live here: a declaration file contains no function body, so it can be wrong about types but never changes behaviour.
- Keep hand-written declarations in a folder included by
tsconfig, and out of the build output. - Prefer
interfacein a declaration file so a consumer can merge it, which is what lets a library be extended.
Types for someone else's package
// src/types/legacy-chart.d.ts - type an untyped dependency locally,
// without waiting for an upstream fix or publishing a fork
declare module 'legacy-chart' {
export interface Options {
width: number;
height: number;
theme?: 'light' | 'dark';
}
export function render(target: string, options: Options): void;
}
// a folder of ambient files can be pulled in with one tsconfig entry
// "typeRoots": ["./node_modules/@types", "./src/types"]
// only the API you use needs a precise shape: leave the rest loose
declare module 'legacy-utils' {
export function format(value: unknown): string;
export const helpers: Record<string, (...args: unknown[]) => unknown>;
}| Situation | Where the types live | Notes |
|---|---|---|
| Package ships types | Its own .d.ts beside the entry point | Nothing to install |
| Community types exist | @types/name in devDependencies | Matched automatically by package name |
| No types anywhere | A local declare module file | Ship the file with your source |
| Types are wrong or stale | A local declaration that narrows the gap | Leave a comment with the upstream issue |
| A global from a script tag | declare global in a module file | Requires export {} to stay a module |
Publishing types with a package
// emit declarations alongside the JavaScript
// tsc --declaration --emitDeclarationOnly --outDir dist
// one entry point re-exports the public surface
export { loadUser, type User } from './user.js';
export { parsePort } from './port.js';
// an explicit public API keeps internals unimportable, so refactors
// inside the package are not breaking changes for consumers
// verify the shipped types resolve the way consumers will resolve them
// npx @arethetypeswrong/cli --pack .⚠️
A published package whose
types path does not resolve breaks every consumer with an error that points at your package and not at the mistake. Ship dist, include the declaration files in files, and check with --traceResolution before the release.FAQ
What does declare actually mean?
It asserts that something exists somewhere else, and emits no code. Since nothing verifies the assertion, a declaration file must be kept in step with the JavaScript it describes by hand.
Where should hand-written .d.ts files live?
In a source folder included by
tsconfig, excluded from the build output. That way they are checked with the rest of the program and never published by accident.Related
Migrating a JavaScript project incrementally Runtime validation at the edges
Last refreshed 2026-09-18.