Writing a TypeScript Library with Type Declarations

1. Overview

TypeScript library users need two things: JavaScript code to run, and type declaration files (.d.ts) so the editor can provide hints and the compiler can catch errors. If .d.ts files are missing or package.json points to the wrong file, users will get any everywhere or errors like Could not find a declaration file for module.

This guide builds a small library called order-pricing for calculating order totals. After reading, you’ll know how to configure tsconfig.json to generate .d.ts files, declare exports in package.json, write declarations manually for old JavaScript code, and test both runtime and types before publishing.

2. Project Setup

You create a directory and install the necessary tools:

mkdir order-pricing && cd order-pricing
npm init -y
npm install --save-dev typescript vitest

typescript only needs to be in devDependencies. Library users don’t need the compiler; they only use the pre-built .js and .d.ts files.

2.1. Configuring tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "rootDir": "src",
    "outDir": "dist",
    "isolatedDeclarations": true
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts"]
}

Meaning of key options:

  • declaration: true tells tsc to generate a .d.ts file alongside each .js file. This is the core option of this guide.
  • declarationMap: true generates .d.ts.map files. When users click “Go to Definition”, the editor opens the original .ts file instead of the .d.ts file. For this to work, you need to publish the src directory along with your code.
  • Set module and moduleResolution to NodeNext so TypeScript resolves modules the same way Node.js does. A library built this way works in both bundlers and Node.
  • isolatedDeclarations (available since TypeScript 5.5) requires all exported APIs to have explicit type annotations, such as function return types. This allows other tools to generate .d.ts files without running the type checker. Public APIs also become more transparent.
  • exclude removes test files from the build so tests don’t end up in dist.

2.2. Configuring package.json

{
  "name": "order-pricing",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist", "src"],
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "prepublishOnly": "npm run test && npm run build"
  }
}

Important points to note:

  • "type": "module" makes Node interpret .js files in dist as ESM.
  • In exports, the types condition must come first. TypeScript walks through conditions in order and stops at the first match. If default comes before types, TypeScript will never reach types.
  • main and types at the root level are fallbacks for older tools that don’t read exports.
  • files limits what gets uploaded to npm. Without this field, you might accidentally publish node_modules or internal config files.

3. Writing Library Code

3.1. Defining Data Types

// src/types.ts
export type CustomerTier = "regular" | "silver" | "gold";

export interface Customer {
  id: string;
  name: string;
  tier: CustomerTier;
}

export interface OrderItem {
  sku: string;
  unitPrice: number;
  quantity: number;
}

export interface Order {
  id: string;
  customer: Customer;
  items: readonly OrderItem[];
}

items is declared as readonly OrderItem[]. This way, users can pass in both regular and readonly arrays, and the library commits to not modifying the user’s array.

3.2. Pricing Logic

// src/pricing.ts
import type { CustomerTier, Order } from "./types.js";

const DISCOUNT_RATE: Record<CustomerTier, number> = {
  regular: 0,
  silver: 0.05,
  gold: 0.1,
};

/** Subtotal before discount, in VND. */
export function calculateSubtotal(order: Order): number {
  return order.items.reduce(
    (sum, item) => sum + item.unitPrice * item.quantity,
    0,
  );
}

/** Total after applying discount based on customer tier. */
export function calculateTotal(order: Order): number {
  const subtotal = calculateSubtotal(order);
  const discount = subtotal * DISCOUNT_RATE[order.customer.tier];
  return Math.round(subtotal - discount);
}

In the code above:

  • import type tells the compiler this is only a type import. This line is completely removed from the .js file after build.
  • The import path is ./types.js even though the source file is types.ts. With NodeNext, this is required because Node will look for the .js file at runtime.
  • DISCOUNT_RATE is not exported, so it’s an internal detail and doesn’t appear in .d.ts.
  • Comments like /** ... */ are preserved in .d.ts. Users see this when hovering in their editor.

3.3. Entry Point

// src/index.ts
export { calculateSubtotal, calculateTotal } from "./pricing.js";
export type { Customer, CustomerTier, Order, OrderItem } from "./types.js";

index.ts determines the library’s public API. You should export types as well. Otherwise, users would have to write Parameters<typeof calculateTotal>[0] just to get the Order type.

4. Building and Viewing .d.ts Files

npm run build

The dist directory structure after build:

dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
├── index.js.map
├── pricing.d.ts
├── pricing.d.ts.map
├── pricing.js
├── pricing.js.map
├── types.d.ts
├── types.d.ts.map
├── types.js
└── types.js.map

Contents of dist/pricing.d.ts:

import type { Order } from "./types.js";
/** Subtotal before discount, in VND. */
export declare function calculateSubtotal(order: Order): number;
/** Total after applying discount based on customer tier. */
export declare function calculateTotal(order: Order): number;
//# sourceMappingURL=pricing.d.ts.map

This file only contains signatures, not function bodies. DISCOUNT_RATE is also absent. dist/types.js is nearly empty (just export {};) because interfaces and type aliases don’t exist at runtime.

If you omit the return type : number on calculateTotal, the isolatedDeclarations option will report an error:

src/pricing.ts(20,17): error TS9007: Function must have an explicit return type annotation with --isolatedDeclarations.

This error works in your favor. If the return type were inferred automatically, a small change in the function body could accidentally change your public API without you knowing it.

5. Testing Runtime and Types

A typed library needs to be tested in two ways: functions return correct values, and types match your commitments. Vitest supports both.

// src/pricing.test.ts
import { describe, expect, expectTypeOf, it } from "vitest";
import { calculateSubtotal, calculateTotal, type Order } from "./index.js";

const goldOrder: Order = {
  id: "ORD-001",
  customer: { id: "C-01", name: "Lan", tier: "gold" },
  items: [
    { sku: "TEA-01", unitPrice: 150_000, quantity: 2 },
    { sku: "CUP-07", unitPrice: 200_000, quantity: 1 },
  ],
};

describe("calculateTotal", () => {
  it("calculates subtotal before discount", () => {
    expect(calculateSubtotal(goldOrder)).toBe(500_000);
  });

  it("applies 10% discount for gold tier", () => {
    expect(calculateTotal(goldOrder)).toBe(450_000);
  });

  it("has the correct type signature", () => {
    expectTypeOf(calculateTotal).parameter(0).toEqualTypeOf<Order>();
    expectTypeOf(calculateTotal).returns.toBeNumber();
  });

  it("rejects unknown customer tiers", () => {
    // @ts-expect-error "diamond" is not a valid CustomerTier
    const tier: Order["customer"]["tier"] = "diamond";
    expect(tier).toBe("diamond");
  });
});

Analysis of each test:

  • The first two tests check the logic: 2 × 150,000 + 200,000 = 500,000, and after a 10% discount it’s 450,000.
  • expectTypeOf locks in the function’s signature. If someone later changes the return type to string, this test will fail.
  • @ts-expect-error asserts that the line below must cause a type error. If "diamond" ever becomes valid, TypeScript will report this directive as unnecessary.

Run the tests:

npm test
 ✓ src/pricing.test.ts (4 tests) 3ms

 Test Files  1 passed (1)
      Tests  4 passed (4)

Note: By default, Vitest only transpiles code and doesn’t type-check. @ts-expect-error and type errors are only checked when you run tsc --noEmit -p tsconfig.test.json (a separate config that includes test files) or vitest --typecheck. You should add this step to CI.

5.1. Checking Your Package Before Publishing

npm pack --dry-run
npm notice 📦  order-pricing@1.0.0
npm notice Tarball Contents
npm notice 1.1kB dist/index.d.ts
npm notice 312B  dist/index.js
...
npm notice 945B  src/pricing.ts
npm notice 402B  src/types.ts

Review this list to make sure you have all the .d.ts files and no test files or secrets. The most thorough check is to run npm pack, install the resulting .tgz in a test project, and try importing. The @arethetypeswrong/cli tool (command attw --pack) also detects most exports and types configuration errors.

6. Writing Declarations Manually for JavaScript Code

Sometimes a library has old JavaScript modules that haven’t been migrated to TypeScript yet. You can write .d.ts files for them yourself.

// src/legacy-tax.js
export function applyVat(amount, rate = 0.08) {
  return Math.round(amount * (1 + rate));
}
// src/legacy-tax.d.ts
/**
 * Add VAT to an amount.
 * @param amount Amount before tax (VND).
 * @param rate Tax rate, defaults to 0.08.
 */
export declare function applyVat(amount: number, rate?: number): number;

Then re-export from index.ts like any other module:

export { applyVat } from "./legacy-tax.js";

Note: tsc does not copy manually written .js and .d.ts files to dist. You have two options. First, enable allowJs: true to have tsc copy legacy-tax.js to dist, then copy legacy-tax.d.ts with a script like "build": "tsc && cp src/legacy-tax.d.ts dist/". Second, enable allowJs with JSDoc and let tsc generate .d.ts from JSDoc. This is a common mistake: it works locally, but users get module not found errors after you publish.

If you need to add types for a third-party package that doesn’t have types, use declare module:

// src/shims/vn-currency.d.ts
declare module "vn-currency" {
  export function format(amount: number): string;
}

Only do this within your project. If your library’s public API depends on this shim, users won’t see it.

7. Common Mistakes

  • types comes after default in exports: TypeScript won’t read types, so users get any or TS7016 errors.
  • Forgetting .js extensions with NodeNext: tsc reports TS2835. If you work around this by switching to moduleResolution: "bundler", the library might work in bundlers but break with plain Node.
  • Exposing types from devDependencies: If Order uses a type from a package in devDependencies, the .d.ts file will import that package. Users won’t have it installed and will get type errors. Any type in your public API must come from dependencies or peerDependencies.
  • Relying on skipLibCheck: This option makes your build faster but also hides errors in the .d.ts files you publish. Test with a sample project that has skipLibCheck off.
  • Carelessly exporting enums or namespaces: const enum causes trouble for users with isolatedModules enabled. String unions like CustomerTier are usually simpler and safer.
  • Changing public types in a patch release: Adding a required field to Order is a breaking change, even if the JavaScript code doesn’t change. Treat .d.ts as part of your API when versioning.

When do you not need to do all this manually? If your library must emit both ESM and CommonJS, use a specialized build tool like tsup or tsdown instead of configuring tsc twice. If the code is only used internally in a monorepo, TypeScript project references are usually cleaner than publishing to npm.

8. Conclusion

This guide built the order-pricing library from scratch with these main steps:

  • Enable declaration, declarationMap, and isolatedDeclarations in tsconfig.json to generate clear and stable .d.ts files.
  • Declare exports in package.json with types first, and limit published files with files.
  • Test both runtime and types with Vitest, expectTypeOf, and @ts-expect-error.
  • Write .d.ts manually for old JavaScript code and remember to include them in dist.

The most important rule is to treat .d.ts files as a contract with your users. Check your package with npm pack before each publish because many configuration errors only show up on the user’s side, not in your project.

typescriptnpmtype-declarationlibrarytsconfig