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: truetellstscto generate a.d.tsfile alongside each.jsfile. This is the core option of this guide.declarationMap: truegenerates.d.ts.mapfiles. When users click “Go to Definition”, the editor opens the original.tsfile instead of the.d.tsfile. For this to work, you need to publish thesrcdirectory along with your code.- Set
moduleandmoduleResolutiontoNodeNextso 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.tsfiles without running the type checker. Public APIs also become more transparent.excluderemoves test files from the build so tests don’t end up indist.
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.jsfiles indistas ESM.- In
exports, thetypescondition must come first. TypeScript walks through conditions in order and stops at the first match. Ifdefaultcomes beforetypes, TypeScript will never reachtypes. mainandtypesat the root level are fallbacks for older tools that don’t readexports.fileslimits what gets uploaded to npm. Without this field, you might accidentally publishnode_modulesor 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 typetells the compiler this is only a type import. This line is completely removed from the.jsfile after build.- The import path is
./types.jseven though the source file istypes.ts. WithNodeNext, this is required because Node will look for the.jsfile at runtime. DISCOUNT_RATEis 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.
expectTypeOflocks in the function’s signature. If someone later changes the return type tostring, this test will fail.@ts-expect-errorasserts 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-errorand type errors are only checked when you runtsc --noEmit -p tsconfig.test.json(a separate config that includes test files) orvitest --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:
tscdoes not copy manually written.jsand.d.tsfiles todist. You have two options. First, enableallowJs: trueto havetsccopylegacy-tax.jstodist, then copylegacy-tax.d.tswith a script like"build": "tsc && cp src/legacy-tax.d.ts dist/". Second, enableallowJswith JSDoc and lettscgenerate.d.tsfrom 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
typescomes afterdefaultinexports: TypeScript won’t readtypes, so users getanyor TS7016 errors.- Forgetting
.jsextensions withNodeNext:tscreports TS2835. If you work around this by switching tomoduleResolution: "bundler", the library might work in bundlers but break with plain Node. - Exposing types from
devDependencies: IfOrderuses a type from a package indevDependencies, the.d.tsfile will import that package. Users won’t have it installed and will get type errors. Any type in your public API must come fromdependenciesorpeerDependencies. - Relying on
skipLibCheck: This option makes your build faster but also hides errors in the.d.tsfiles you publish. Test with a sample project that hasskipLibCheckoff. - Carelessly exporting enums or namespaces:
const enumcauses trouble for users withisolatedModulesenabled. String unions likeCustomerTierare usually simpler and safer. - Changing public types in a patch release: Adding a required field to
Orderis a breaking change, even if the JavaScript code doesn’t change. Treat.d.tsas 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, andisolatedDeclarationsintsconfig.jsonto generate clear and stable.d.tsfiles. - Declare
exportsinpackage.jsonwithtypesfirst, and limit published files withfiles. - Test both runtime and types with Vitest,
expectTypeOf, and@ts-expect-error. - Write
.d.tsmanually for old JavaScript code and remember to include them indist.
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.