Publishing npm packages with TypeScript definitions is surprisingly simple once you understand the core mechanism: the files and types fields in package.json.
Let’s say you have a simple utility library.
// src/index.ts
export function greet(name: string): string {
return `Hello, ${name}!`;
}
And you want to publish this so that TypeScript users get autocompletion and type checking.
First, you need to compile your TypeScript to JavaScript. A common setup uses tsc with a tsconfig.json.
// tsconfig.json
{
"compilerOptions": {
"target": "es2018",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true, // This is key for generating .d.ts files
"declarationDir": "./dist", // Where to output the .d.ts files
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
The declaration: true option tells tsc to generate .d.ts files alongside your compiled JavaScript. declarationDir specifies where these type definition files should go.
After running tsc, your dist folder will look something like this:
dist/
├── index.js
└── index.d.ts
Now, you need to tell npm which files to include when you publish. This is done via the files array in your package.json. Crucially, you want to include your compiled JavaScript and your TypeScript definitions.
// package.json
{
"name": "my-ts-lib",
"version": "1.0.0",
"description": "A simple TypeScript library",
"main": "dist/index.js",
"types": "dist/index.d.ts", // This tells TypeScript where to find the main type definition file
"files": [
"dist" // This includes everything in the dist folder
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build" // Ensures build runs before publishing
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
The main field points to your entry point JavaScript file. The types field is the most important for TypeScript consumers; it tells the TypeScript compiler exactly which .d.ts file to load when someone imports your package. If you have multiple entry points, you’ll have multiple .d.ts files and potentially multiple types entries or a more complex structure.
The files array is critical. If you only include "dist", npm will package up everything inside the dist directory, which is exactly what we want: index.js and index.d.ts. If you don’t specify files, npm defaults to including everything not in .gitignore or .npmignore, which can sometimes lead to publishing unwanted files. Explicitly listing files is best practice.
The prepublishOnly script is a lifesaver. It ensures that npm run build (which runs tsc) is executed automatically before npm publish is run. This guarantees that your type definitions are up-to-date with your compiled code.
When someone installs your package (npm install my-ts-lib), their TypeScript compiler will automatically look at the types field in your package.json to find dist/index.d.ts. When they import { greet } from 'my-ts-lib';, TypeScript will use dist/index.d.ts to provide autocompletion and type checking for the greet function.
The magic happens because tsc generates a index.d.ts file that mirrors the shape of your JavaScript code. For our example, dist/index.d.ts would look like this:
// dist/index.d.ts (generated by tsc)
export declare function greet(name: string): string;
This file is what the TypeScript compiler reads. It doesn’t contain any implementation details, just the "contract" of your code: what functions exist, what arguments they take, and what they return.
The most surprising thing about publishing type definitions is how little you actually need to do beyond configuring your build process and package.json correctly. The tsc compiler handles the heavy lifting of generating the .d.ts files, and npm’s files and types fields are the glue that makes it all work for consumers. It’s not about manually writing .d.ts files for your own published code; it’s about letting the compiler do it.
If you are publishing a library with multiple entry points (e.g., using exports in package.json for subpath imports), you’ll need to ensure your tsconfig.json and package.json reflect this structure, potentially generating multiple .d.ts files and mapping them correctly in the exports field and types field (or using a "typesVersions" map for compatibility).
The next hurdle is often handling complex types, like generics or conditional types, which can sometimes generate .d.ts files that are harder for other developers to use directly, requiring them to understand the underlying type system.