Skip to Content
TypeScript Advanced: Types That Hold Up in Production
Advanced
0 likes
Courses
TypeScript Advanced: Types That Hold Up in Production

Writing .d.ts for a Plain JS Module

Write a declaration file by hand for a JavaScript module, start from a generated draft and test that the declaration still matches. After this, a plain JavaScript import gets real types and the JavaScript stays untouched.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already know why .d.ts files sit next to the JavaScript they describe, skip to "Write the declaration by hand", then continue.

Sooner or later a TypeScript file imports something that is plain JavaScript: a helper module nobody has converted, a script from another team, or a package that ships no types. The compiler stops you with an error that sounds like a dead end. It is not. A declaration file, .d.ts, tells TypeScript what the JavaScript looks like without touching the JavaScript. This lesson writes one by hand for a module you control, because that is where you learn what the file is actually promising.

All the examples run as ES modules, so set that once in the project.

npm pkg set type=module

The module and the error

Here is a small module with the usual unconverted habits: no types, a default parameter, an options object and a class.

export const TAX_RATE = 0.12;

export function withTax(amount, rate = TAX_RATE) {
  return Math.round(amount * (1 + rate) * 100) / 100;
}

export function formatPrice(amount, options = {}) {
  const { currency = "PHP", digits = 2 } = options;
  return `${currency} ${amount.toFixed(digits)}`;
}

export class Cart {
  constructor() {
    this.items = [];
  }
  add(name, price) {
    this.items.push({ name, price });
    return this;
  }
  total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}
import { withTax, formatPrice, Cart } from "./prices.js";

console.log(formatPrice(withTax(100)));
console.log(new Cart().add("pen", 12).total());
app.ts(1,44): error TS7016: Could not find a declaration file for module './prices.js'. '/path/to/project/prices.js' implicitly has an 'any' type.

The error is TS7016 and it is about strictness. The compiler found prices.js, saw no types for it, and refuses to give every export the type any quietly. You have four ways out. A // @ts-ignore above the import hides the error and keeps the any. A one-line declare module "some-package"; makes a package import any on purpose, but it only works for package names, not for relative paths like this one. Turning on allowJs makes the compiler read the JavaScript and guess, which the next section shows is not much better. A declaration file is the only option that gives you real types, and it is the one that leaves the JavaScript alone.

Start from a generated draft

You do not have to type it from nothing. The compiler can write a first version from the JavaScript.

npx tsc prices.js --allowJs --declaration --emitDeclarationOnly --outDir draft
cat draft/prices.d.ts
export declare const TAX_RATE = 0.12;
export declare function withTax(amount: any, rate?: number): number;
export declare function formatPrice(amount: any, options?: {}): string;
export declare class Cart {
    items: any[];
    constructor();
    add(name: any, price: any): this;
    total(): any;
}

Count the anys. The compiler can see the default rate = TAX_RATE is a number and that Cart.add returns this, but it cannot know what amount is, so it gives up. That is the part only you can fill in. Treat the output as a list of exports you must not forget, and nothing more.

Write the declaration by hand

Create prices.d.ts next to prices.js. When the compiler resolves ./prices.js it finds the .d.ts with the same name.

export const TAX_RATE: number;

export function withTax(amount: number, rate?: number): number;

export interface PriceOptions {
  currency?: string;
  digits?: number;
}
export function formatPrice(amount: number, options?: PriceOptions): string;

export class Cart {
  items: { name: string; price: number }[];
  add(name: string, price: number): this;
  total(): number;
}
import { withTax, formatPrice, Cart } from "./prices.js";

console.log(formatPrice(withTax(100)));
console.log(new Cart().add("pen", 12).total());
PHP 112.00
12

A few rules make this file shorter than you expect. There is no function body: a declaration ends in a semicolon. A parameter with a default in the JavaScript becomes optional here, written with ?. A class lists its members and their types, with no constructor body. And everything that the module exports must appear, because a name that is missing from the .d.ts does not exist as far as TypeScript is concerned.

💡 Tip: Put the options object in a named interface. Callers can import PriceOptions and the next person reading the file sees what the options are without opening the JavaScript.

The compiler does not compare the two files

This is the part to remember. Nothing checks that prices.d.ts matches prices.js. If the declaration lies, the compiler believes it.

export function double(n) {
  return n * 2;
}
export function double(n: string): string;
import { double } from "./lies.js";

console.log(double("4").toUpperCase());
npx tsc lies_app.ts
node lies_app.js 2>&1 | grep TypeError
TypeError: double(...).toUpperCase is not a function

The declaration claims a string goes in and a string comes out. The JavaScript multiplies, so a number comes out, and toUpperCase does not exist on it. The compile was clean. This is why a hand-written .d.ts needs a test, even a tiny one. Add a types.test.ts that calls each export with the right arguments and uses // @ts-expect-error on a call that should fail, and run tsc --noEmit in CI. If someone changes the JavaScript and forgets the declaration, at least the examples of how it is used will fail to match.

⚠ Warning: Never write any in a declaration to make an error go away, unless you are leaving a hole on purpose and you say so in a comment. Every any in a .d.ts spreads to every file that imports it.

A CommonJS module

Older modules use module.exports = ... and have no named exports. The declaration uses export =, and the file extension tells TypeScript which kind of module it describes: .d.cts for a .cjs file.

module.exports = function slugify(text) {
  return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
};
declare function slugify(text: string): string;
export = slugify;
import slugify from "./slugify.cjs";

console.log(slugify("  Hello, TypeScript World! "));
hello-typescript-world

declare function is needed here because there is no export in front of it. In a .d.ts, a top-level declaration needs either export or declare. If the file has no import or export at all, TypeScript treats it as a script and every declaration becomes global. Sometimes that is what you want. You will see how to do it on purpose in the next lesson.

Common mistakes

  • Writing the declaration and forgetting to list a function that the JavaScript exports. The import then fails with a "has no exported member" error, which is at least easy to find.
  • Describing what the code was meant to do, not what it does. Read the JavaScript for how it handles undefined and null before you write string instead of string | undefined.
  • Putting the .d.ts in a types folder that the compiler does not include. A declaration next to its JavaScript is found by name. Anywhere else it must be in include or typeRoots.

If you skip this, every import of that JavaScript is any or a @ts-ignore, and the errors you were hoping the compiler would catch go with it. If a teammate changed formatPrice tomorrow and did not touch the .d.ts, which line in your repository would tell you?

About

Write a declaration file by hand for a JavaScript module, start from a generated draft and test that the declaration still matches. After this, a plain JavaScript import gets real types and the JavaScript stays untouched.

Duration
10 min
Last update
09/29/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.