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 requires
  • declare asserts 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 interface in 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>;
}
SituationWhere the types liveNotes
Package ships typesIts own .d.ts beside the entry pointNothing to install
Community types exist@types/name in devDependenciesMatched automatically by package name
No types anywhereA local declare module fileShip the file with your source
Types are wrong or staleA local declaration that narrows the gapLeave a comment with the upstream issue
A global from a script tagdeclare global in a module fileRequires 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.

Migrating a JavaScript project incrementally Runtime validation at the edges

Last refreshed 2026-09-18.