Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. TypeScript Declaration Files: JavaScript Contracts

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Writing Your Own Declaration Files in TypeScript
TypeScript
12m
Oct 27, 2026

Writing Your Own Declaration Files in TypeScript

A declaration file is an API design artifact. Learn to model JavaScript honestly, distinguish modules from globals, and test the contract consumers will actually import.

#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
ES Modules in TypeScript
TypeScript
12m
Sep 29, 2026

ES Modules in TypeScript

ES modules are more than import syntax. Learn how module boundaries, type-only imports, compiler resolution, and package exports work together in a production TypeScript codebase.

#TypeScript#ES Modules+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • A Declaration Is a Contract, Not Code
  • Where TypeScript Finds Declarations
  • Modules and Globals Have Different Shapes
  • Read Types as You Would an API
  • Diagnose Missing and Misleading Types
  • Key Takeaways
TypeScript

TypeScript Declaration Files: JavaScript Contracts

October 13, 2026•6 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Centred diagram of JavaScript implementation connected to a precise TypeScript declaration contract
TypeScript Declaration Files: JavaScript Contracts

TypeScript runs no type information at runtime. JavaScript executes the same way whether a file has beautiful types, inaccurate types, or no types at all. So when TypeScript understands a JavaScript package, it is working from a separate description: a declaration file, usually ending in .d.ts.

That distinction is the key to using declarations well. A declaration file describes the surface other code may rely on. It does not implement that surface. Think of it as a contract placed beside the executable JavaScript.

A Declaration Is a Contract, Not Code

Here is a tiny JavaScript library:

// format-price.js
export function formatPrice(cents, currency = "USD") {
  return new Intl.NumberFormat("en-CA", {
    style: "currency",
    currency,
  }).format(cents / 100);
}

The corresponding declaration describes callers’ view of the function:

// format-price.d.ts
export function formatPrice(cents: number, currency?: string): string;

There is no function body because TypeScript does not execute this file. The semicolon is a promise: callers may pass a number and an optional string, and they will receive a string. Editors use that promise for completions and errors; the compiler uses it to check imports; consumers use it as documentation.

Declarations Can Be Wrong

A declaration can claim a function returns a string while its runtime implementation returns null. TypeScript will trust the declaration. Type safety is strongest when runtime behaviour, tests, and the public type contract agree.

This is why declaration work deserves the same care as public API design. A type that is too broad hides mistakes. A type that is too narrow blocks legitimate consumers. A type that lies is worse than an obvious compiler error because it creates confidence without compatibility.

Where TypeScript Finds Declarations

When you import a package, TypeScript looks for its type information using the package metadata and its configured module-resolution rules. A package can ship its own declarations:

{
  "name": "tiny-price-tools",
  "type": "module",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

The types entry points TypeScript at the package’s main declaration file. A conditional export can specify a declaration alongside its runtime entry point, which is particularly helpful for packages with several public subpaths. The important rule from the previous module-resolution article still applies: consume supported exports rather than guessing at files inside dist.

If a JavaScript package does not ship types, the community may provide them through an @types package, commonly installed as a development dependency:

pnpm add -D @types/lodash

DefinitelyTyped is a large ecosystem of these community-maintained declarations. It is a useful bridge, but verify the installed version matches the runtime package you actually use. A type package for an older major version may produce confusing errors—or accept APIs that vanished long ago.

For your own project, TypeScript also includes .d.ts files that are part of the compilation and those deliberately included through tsconfig.json. Do not reach first for a blanket declare module "*"; it silences valuable feedback for every unknown import.

Modules and Globals Have Different Shapes

Most modern declarations describe modules. A file with a top-level export describes what consumers get from an import:

// analytics-kit.d.ts
export type EventName = "checkout_started" | "checkout_completed";
 
export function track(
  name: EventName,
  properties: Record<string, string>
): void;
import { track } from "analytics-kit";
 
track("checkout_started", { source: "cart" });

Some browser scripts instead attach a value to window. That needs a global augmentation, normally in a module file so it does not accidentally pollute every declaration’s scope:

export {};
 
declare global {
  interface Window {
    analytics?: { track(name: string): void };
  }
}

The optional marker mirrors runtime reality: third-party scripts may not have loaded yet. A good declaration expresses that possibility and forces callers to make a safe choice.

window.analytics?.track("newsletter_opened");

Ambient module declarations solve a different problem: describing an import whose package has no types. Keep them narrow and close to the actual exported shape.

declare module "legacy-slugger" {
  export function slugify(value: string): string;
}

That is far safer than declaring every import as any. It preserves checking at the call site and creates a visible to-do for replacing, contributing, or improving the contract.

Read Types as You Would an API

Declaration files are often the most precise documentation a package has. Before fighting a compiler error, inspect the types it installed. In an editor, Go to Definition usually lands on the declaration. From the terminal, find the package’s types or exports fields and follow the named entry point.

Pay special attention to these details:

  • readonly tells you whether mutation is part of the contract.
  • undefined and null tell you which absence states exist at runtime.
  • overloads show distinct supported call patterns, not a request to invent a hybrid one.
  • generic parameters reveal what relationship the library preserves between input and output.
  • unknown means the library cannot safely promise more without your validation.

For example, this declaration says a parser may fail, and that callers must prove the result before accessing value:

type ParseResult = { ok: true; value: Date } | { ok: false; message: string };
 
declare function parseDate(input: string): ParseResult;
const result = parseDate(formValue);
 
if (!result.ok) {
  showError(result.message);
} else {
  saveDate(result.value);
}

The declaration is communicating a workflow, not merely satisfying a compiler.

Diagnose Missing and Misleading Types

When TypeScript cannot find types, diagnose the boundary rather than adding suppression immediately.

  1. Confirm that the runtime package is installed and that the import path is a documented public path.
  2. Inspect its package.json for types, typings, and exports conditions.
  3. Check whether the package ships types or expects a matching @types/... dependency.
  4. Confirm your module and moduleResolution settings model the environment that actually runs the code.
  5. Add the narrowest local declaration only when a real contract is known, then test that contract against the runtime library.

If a declaration is misleading, prefer an upstream fix or a local wrapper over a global cast at every call site. A wrapper creates one explicit boundary between untrusted or poorly typed code and the rest of the application.

import legacySlugger from "legacy-slugger";
 
export function toSlug(value: string): string {
  const result: unknown = legacySlugger(value);
  if (typeof result !== "string") {
    throw new Error("legacy-slugger returned a non-string result");
  }
  return result;
}

This may look more verbose than as string, but it assigns responsibility correctly. The questionable dependency is verified once; the rest of the codebase receives a reliable string contract.

Key Takeaways

  • A .d.ts file describes JavaScript for the compiler; it does not change runtime behaviour.
  • Package-owned declarations and matching @types packages are the usual sources of library types.
  • Module declarations, global augmentations, and ambient module declarations solve different integration problems.
  • Read installed declarations as precise API documentation, especially for absence, overloads, and generics.
  • Fix missing or inaccurate types at a narrow boundary rather than masking them with broad any declarations or repeated casts.

Next, we will build on this by writing declarations of our own—carefully modelling a JavaScript library, augmenting an existing module, and keeping the type contract honest as the runtime evolves.