Declaration Files
Writing .d.ts files, augmenting global types, @types/ version skew, and generating declarations from OpenAPI or GraphQL schemas instead of handwriting them.
A declaration file (.d.ts) describes the shape of a JavaScript module to TypeScript without containing any runtime code. It is the bridge between the untyped JS ecosystem and TypeScript's type system.
A .d.ts file declares types for a module. The declarations are erased at compile time - they exist only for the type checker. If a package ships without types, you write (or install) a .d.ts to tell TypeScript what the package exports.
// types/untyped-logger.d.ts
// Describes a hypothetical "untyped-logger" npm package that has no @types/ package
declare module "untyped-logger" {
export interface LogLevel {
level: "debug" | "info" | "warn" | "error";
}
export interface Logger {
debug(msg: string, meta?: Record<string, unknown>): void;
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, error?: Error, meta?: Record<string, unknown>): void;
}
export function createLogger(name: string): Logger;
export const rootLogger: Logger;
}// Now this import is fully typed
import { createLogger } from "untyped-logger";
const log = createLogger("api");
log.info("server started", { port: 3000 });The /// <reference types="..." /> directive adds type declarations from a package to the global scope. Global augmentation extends built-in types - useful for adding custom properties to window, process.env, or Express's Request.
// global.d.ts - augment Express Request with your auth context
import "express";
declare global {
namespace Express {
interface Request {
user?: {
id: string;
role: "admin" | "member";
};
}
}
}
// env.d.ts - type process.env entries so missing vars are a type error
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
JWT_SECRET: string;
NODE_ENV: "development" | "production" | "test";
PORT?: string; // optional - has a default
}
}
}// reference directive - include a types package in this file's scope
/// <reference types="node" />
const buf = Buffer.from("hello"); // Buffer is available because of the reference@types/ packages (from DefinitelyTyped) provide types for untyped npm packages. They are versioned independently of the library - a library patch release can silently break under an old @types version or vice versa. Lock both versions in package.json.
// package.json - lock library and @types/ versions together
{
"dependencies": {
"express": "4.18.2" // runtime library
},
"devDependencies": {
"@types/express": "4.17.21" // lock major.minor to match library
// A mismatch - e.g., @types/express@5.x with express@4.x - causes
// type errors that are invisible until you update and break everything.
}
}// Check if a package ships its own types (no @types/ needed):
// Look for "types" or "typings" in the package's package.json.
// If present, @types/ is unnecessary and may conflict.
import type { RequestHandler } from "express"; // from @types/expressFor packages you own, TypeScript generates .d.ts files automatically from source when declaration: true is set in tsconfig.json. For API clients, generate types from your OpenAPI or GraphQL schema - two handwritten sources of truth for the same contract always diverge.
// tsconfig.json - emit declarations alongside compiled JS
{
"compilerOptions": {
"declaration": true, // emit .d.ts for every .ts file
"declarationMap": true, // emit .d.ts.map for Go-to-Definition in source
"declarationDir": "dist/types", // output declarations to a separate folder
"outDir": "dist"
}
}# Generate TypeScript types from an OpenAPI spec (openapi-typescript)
npx openapi-typescript openapi.yaml -o src/types/api.d.ts
# Generate TypeScript types from a GraphQL schema (graphql-code-generator)
npx graphql-codegen --config codegen.yml// Import the generated types - no manual maintenance
import type { components } from "@/types/api";
type User = components["schemas"]["User"];
type CreateUserRequest = components["schemas"]["CreateUserRequest"];In production
@types/ packages are maintained separately from the library they describe - a patch release of the library can quietly break under an old @types version, or a new @types version can introduce types incompatible with the runtime behavior you rely on. Lock both express and @types/express to matching major.minor versions in package.json. For teams that own their API: generate .d.ts files from an OpenAPI spec or GraphQL schema rather than handwriting them. Two sources of truth for the same contract always diverge - the spec diverges from the implementation, and the handwritten types diverge from the spec. A generated type file eliminates one of those gaps at zero maintenance cost.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free