Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Writing Your Own Declaration Files in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

TypeScript Declaration Files: JavaScript Contracts
TypeScript
11m
Oct 13, 2026

TypeScript Declaration Files: JavaScript Contracts

A .d.ts file is not an implementation. It is a public contract that lets TypeScript reason about JavaScript safely and usefully.

#TypeScript#Declaration Files+4
Using @types Packages in TypeScript
TypeScript
11m
Oct 20, 2026

Using @types Packages in TypeScript

@types packages are useful compatibility contracts for JavaScript libraries—provided their versions, visibility, and runtime behaviour stay aligned.

#TypeScript#@types+4
Setting Up Your TypeScript Development Environment
TypeScript
8m
Nov 13, 2025

Setting Up Your TypeScript Development Environment

Getting started with TypeScript begins with proper setup. This comprehensive guide walks you through installing TypeScript, understanding tsconfig.json, and configuring your IDE for maximum productivity. Whether you're starting a new project or migrating an existing one, you'll learn the essential setup steps and best practices for a smooth TypeScript development experience.

#TypeScript#Setup+6
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Start From Runtime Reality
  • Model a Public Module Contract
  • Choose Ambient Declarations or Augmentations Carefully
  • Globals and Plugins Need Extra Restraint
  • Test and Ship the Contract
  • Key Takeaways
TypeScript

Writing Your Own Declaration Files in TypeScript

October 27, 2026•7 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred declaration authoring diagram connecting runtime JavaScript behaviour, a precise TypeScript contract, type tests, and consumers
Writing Your Own Declaration Files in TypeScript

Writing a declaration file is not a decorative task after the JavaScript is done. It is an act of public API design. The .d.ts file tells TypeScript consumers what they may import, call, construct, inspect, and safely assume. If it does not match runtime behaviour, it creates a polished route to production bugs.

The discipline is straightforward: observe the runtime first, model only what you can support, choose the declaration form that matches how consumers load the library, and compile examples as tests. The syntax is often the easy part; the hard part is resisting assumptions about the API you wish existed.

Start From Runtime Reality

Begin with the way real JavaScript consumers use the library. Read its entry point, package metadata, and examples. Does it expose named ESM exports? Assign one callable value to module.exports? Attach a global after a script tag? Mutate an existing object only when imported for side effects?

The TypeScript library-structures guide (opens in new tab) is explicit that declaration structure follows library consumption structure. A declaration that uses export default for a runtime package consumed with require("thing")() may type-check only with a fortunate compiler setting while describing the wrong contract.

Make a small runtime inventory before writing types:

// index.js
export function createSlug(value, options = {}) {
  const separator = options.separator ?? "-";
  return value.trim().toLowerCase().replace(/\s+/g, separator);
}
 
export const version = "2.0.0";

The inventory answers useful questions:

  • Is createSlug a named export, a default export, or a property on a callable object?
  • Which inputs are optional, and what defaults apply at runtime?
  • Can the function throw, return undefined, mutate an argument, or accept unknown values?
  • Which values are intentionally public versus merely present in a build directory?

Run representative examples. If JavaScript accepts only strings but crashes on an object, do not declare unknown and claim every value is supported. If an option is ignored in the current implementation, it is not a supported option just because a future version may want it.

Model a Public Module Contract

For the ESM example, a matching declaration is pleasantly small:

// index.d.ts
export interface SlugOptions {
  separator?: "-" | "_";
}
 
export function createSlug(value: string, options?: SlugOptions): string;
 
export const version: string;

Every choice communicates something to consumers. A union for separator reflects a finite runtime vocabulary. The optional property reflects the default. Returning string says empty input still produces a string. If the runtime preserves any custom separator string, the more honest type is separator?: string—precision is valuable only when it is true.

Prefer named types for meaningful concepts, but do not invent abstraction just to make a declaration look sophisticated. Consumers should be able to understand the API from editor hover text. The declaration reference (opens in new tab) is a useful catalogue for common runtime shapes: properties, functions, overloads, classes, namespaces, and global values.

For a callable CommonJS export with a property, the runtime relationship must be reflected too:

function format(value) {
  return value.trim();
}
format.defaultLocale = "en-CA";
module.exports = format;
declare function format(value: string): string;
 
declare namespace format {
  let defaultLocale: string;
}
 
export = format;

This is one of the situations where declaration merging earns its complexity: the import is both callable and has a property. Do not replace it with an object-shaped export just because object syntax is familiar.

Do Not Type Private Build Files

A declaration is a promise about supported imports. Deep files discovered inside dist are implementation details unless the package export map intentionally exposes them. Typing them makes consumers depend on a layout you cannot safely change.

Choose Ambient Declarations or Augmentations Carefully

declare module "name" has two meanings that are easy to confuse. In a script-like .d.ts file it can introduce an ambient module for an untyped library. In a module file—one with top-level import or export—the same syntax augments an existing module.

Use an ambient module when no original declarations exist and you are supplying the initial contract:

// types/legacy-tokenizer.d.ts
declare module "legacy-tokenizer" {
  export function tokenize(input: string): string[];
}

Use module augmentation when a library already has declarations and your runtime setup genuinely adds to them:

// types/request-context.d.ts
import "express";
 
declare module "express-serve-static-core" {
  interface Request {
    requestId: string;
  }
}

The top-level import intentionally makes this file a module augmentation. TypeScript’s module reference (opens in new tab) warns that moving an import or export can change an ambient module into an augmentation entirely. Keep this distinction visible in code review, because a misplaced export {} can replace an intended declaration with a patch to a module that does not have the member you expected.

Augmentations merge declarations as though they were written in the original file, but they cannot add new top-level declarations to that module and cannot augment a default export. If your desired extension needs a new runtime wrapper, write a wrapper with its own public type rather than forcing an augmentation beyond its semantics.

Globals and Plugins Need Extra Restraint

Global declarations have a large blast radius. A browser SDK loaded by a <script> tag may warrant a global contract:

declare namespace AcmeAnalytics {
  interface EventProperties {
    [key: string]: string | number | boolean;
  }
}
 
declare const acmeAnalytics: {
  track(event: string, properties?: AcmeAnalytics.EventProperties): void;
};

Namespace the supporting types beneath the global value instead of adding generic top-level names such as Options or Event. The library-structures guide cautions that global names create potential conflicts that can become impossible to resolve across a dependency graph.

If importing a module truly changes a global or prototype, model both the activation and the change:

// runtime: import "array-tools" adds Array.prototype.first
export {};
 
declare global {
  interface Array<T> {
    first(): T | undefined;
  }
}

This is a valid pattern, not a default. Prototype patching can collide with other libraries and surprises consumers who forget the side-effect import. The global-modifying module template (opens in new tab) describes the shape; it also makes clear why this family of libraries deserves caution.

Test and Ship the Contract

Type-checking the declaration file itself proves very little. Write consumer-shaped type tests that import the package through the supported entry points and assert both valid and invalid usage.

import { createSlug } from "tiny-slug";
 
createSlug("Hello World");
createSlug("Hello World", { separator: "_" });
 
// @ts-expect-error separator is intentionally limited by the public contract
createSlug("Hello World", { separator: "/" });
 
// @ts-expect-error the runtime requires text input
createSlug(42);

Use @ts-expect-error rather than comments that merely describe an expected failure. If a future type change makes the call valid, TypeScript reports the unused directive and turns the test into useful regression evidence. Pair type tests with runtime tests for the same examples. One proves the contract allows and rejects the intended calls; the other proves JavaScript honours the contract.

For TypeScript source, prefer generated declarations where possible:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist"
  }
}

The declaration compiler option (opens in new tab) emits .d.ts files describing the external module API. Hand-author declarations when modelling JavaScript, unusual runtime shapes, or intentional public facades; otherwise let the source and declaration evolve together through the compiler.

Finally, package only the artifacts consumers need. Point package.json types and export-map types conditions at real generated or authored files, test installation from the packed tarball, and test both the current TypeScript version and any versions you support. A declaration that works only in the monorepo is not yet a published contract.

Key Takeaways

  • Start from actual runtime import and execution behaviour; declaration form follows consumption form.
  • Model only supported public APIs, including defaults, absence, mutability, callable exports, and intentional entry points.
  • Distinguish an ambient declaration for an untyped module from an augmentation that patches an existing declaration.
  • Keep globals and prototype modifications minimal, namespaced, and explicit about their side-effect activation.
  • Test declarations as a consumer would, pair type tests with runtime tests, and package the exact files your export map promises.

Next, we will return to Next.js caching: Cache Components and use cache, where explicit cache boundaries can improve both performance and the clarity of dynamic data flows.