19.7 Generating Declaration Files with tsc --declaration
Right, so you’ve written some beautiful TypeScript. It’s clean, it’s typed, it’s a work of art. But now you want to share your magnificent library with the poor souls still stuck in the jungles of plain JavaScript. You can’t just hand them the raw .js files; that would be like giving someone an IKEA flat-pack without the instruction manual. They’d have no idea how to use your functions without getting a splinter from a rogue any. This is where tsc --declaration comes in. It’s your compiler’s way of generating that instruction manual: a .d.ts declaration file.
Think of a .d.ts file as a public API brochure for your library. It describes what your code does (the types, the interfaces, the function signatures) without including how it does it (the actual implementation logic). This allows JavaScript users to get full IntelliSense and type-checking in their editors, all while your proprietary secret sauce remains safely compiled away in the .js file.
How to Generate Declaration Files
You generate declaration files by passing the --declaration flag to the TypeScript compiler, tsc. In practice, you’ll almost always do this via your tsconfig.json file because, let’s be honest, who remembers all those flags?
// tsconfig.json
{
"compilerOptions": {
"declaration": true, // This is the magic switch
"outDir": "./dist", // Keep your built .js and .d.ts files tidy
"rootDir": "./src", // Important for correct file structure
"target": "es2017",
"module": "esnext"
},
"include": ["src/**/*"]
}
Now, when you run tsc, it will not only compile your src/index.ts to dist/index.js but also generate a corresponding dist/index.d.ts. It’s a two-for-one deal.
The Inevitable “Cannot Find Name” Error
Here’s where everyone trips up the first time. The compiler can only generate declarations for what it knows about. If you’re using a type, interface, or function from another file, it must be exported. Otherwise, it’s considered a private implementation detail and the declaration generator will rightfully refuse to leak it.
Consider this setup:
// src/utilities.ts
// This is NOT exported. It's our secret.
const superSecretAlgorithm = (input: string): number => { ... };
// This IS exported. It's public API.
export const publicHelper = (data: string): number => {
return superSecretAlgorithm(data);
};
// src/index.ts
export { publicHelper } from './utilities';
When you compile this with --declaration, the generated index.d.ts will look something like:
// dist/index.d.ts
export declare const publicHelper: (data: string) => number;
Notice that superSecretAlgorithm is nowhere to be found. Good. That’s what we want. If you hadn’t exported publicHelper, you’d get nothing in your declaration file for that entire module, which might be what you want for an internal utility file.
The Critical Link Between Declaration Files and Source Maps
This is a pro-tip that will save you hours of debugging headache. If you’re using source maps (--sourceMap), which you should be for debugging your compiled JS, you need to also generate declaration source maps.
{
"compilerOptions": {
"declaration": true,
"declarationMap": true, // <- This one!
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
Why? Because when a developer using your library jumps to the definition of your function, their editor will take them to the generated dist/index.d.ts file. With declarationMap enabled, it can then trace that definition back to the original src/index.ts source file. Without it, they’re stuck looking at the generated declaration, which is a much less pleasant experience. It’s a small bit of configuration that makes your library feel infinitely more professional.
What Gets Emitted and What Doesn’t
The declaration emitter is smart, but it has rules. It only emits type information for things that are exported. This includes:
- Exported variables, functions, and classes (with their types)
- Exported interfaces and type aliases
- The types of parameters and return values, even if they are defined by non-exported types (this is a common point of confusion).
Wait, what? That last point is crucial. Let’s look at an example.
// src/types.ts
// This interface is NOT exported. It is private.
interface InternalConfig {
apiKey: string;
retries: number;
}
// This function IS exported.
export const initialize = (config: InternalConfig): void => { ... };
In this case, the initialize function is part of your public API. Its signature must be described in the declaration file. So what does dist/index.d.ts look like?
// dist/index.d.ts
declare interface InternalConfig {
apiKey: string;
retries: number;
}
export declare const initialize: (config: InternalConfig) => void;
See that? The compiler had to “lift” the InternalConfig interface into the declaration file because a public API depends on it. It’s smart enough to do this, but it’s a clear sign that your types might be leakier than you intended. If you don’t want InternalConfig to ever be seen by the outside world, you need to refactor your public function to use a type that is exported. The compiler is brutally honest in its declarations; it shows exactly what your exports require.