The source, the config that writes the declarations, and the .d.ts file shown below are one project. When this site is built, the source is checked and the declarations are written again in memory with TypeScript 5.9; the build fails if they no longer match the file on this page.
Install
A library's own tooling needs only TypeScript. Add @types/node (and "node" in types) if the library uses Node.js APIs.
npm install --save-dev typescript jsconfig.json
This config checks; it never writes. TypeScript's handbook suggests module: "node18" for libraries: code that works in Node.js almost always works in bundlers too, and the setting stays put across TypeScript releases. Set target to the oldest JavaScript version you support, since it decides which built-in methods the checker lets you use.
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"module": "node18",
"target": "es2022",
"types": [],
"skipLibCheck": true
},
"include": ["src/**/*.js"]
} The config that writes the .d.ts files
It extends jsconfig.json and switches writing on for declarations only. "noEmit": false is required: a config that extends a jsconfig.json inherits its noEmit: true, and tsc would then write nothing, without an error. See declaration for each option.
{
"extends": "./jsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "types"
}
} package.json
exports lists the types condition first, then default; TypeScript's 4.7 release notes say the types condition should come first. files publishes the source and the declarations. prepack runs npm run types before npm pack and npm publish, so the .d.ts files are always fresh and can stay out of git.
{
"name": "tiny-slug",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./types/index.d.ts",
"default": "./src/index.js"
}
},
"files": ["src", "types"],
"scripts": {
"lint": "tsc -p jsconfig.json",
"lint:watch": "tsc -p jsconfig.json --watch --preserveWatchOutput",
"types": "tsc -p jsconfig.types.json",
"prepack": "npm run types"
}
} What users of the package get
This is the file npm run types writes from src/index.js. The @typedef became an exported type, the optional parameter stayed optional, and the JSDoc descriptions came along for hovers.
/**
* @typedef {object} SlugOptions
* @property {string} [separator] Put between words. Default "-".
* @property {number} [maxLength] Cut the slug to this many characters.
*/
/**
* Turn a title into a URL slug.
* @param {string} title
* @param {SlugOptions} [options]
* @returns {string}
*/
export function slugify(title: string, options?: SlugOptions): string;
export type SlugOptions = {
/**
* Put between words. Default "-".
*/
separator?: string | undefined;
/**
* Cut the slug to this many characters.
*/
maxLength?: number | undefined;
};jsconfig.types.json, the config that writes it
{
"extends": "./jsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "types"
}
} Users who still write CommonJS
The package is ES modules only. Node.js lets CommonJS code require() it without a flag from 20.19 and 22.12, as long as it has no top-level await, and TypeScript 5.8 and later model that under module: "nodenext". Supporting older Node.js from CommonJS would need a second, CommonJS build, which is beyond a no-build setup.
The example project
One module with an options object: the kind of API where JSDoc types help users most.
/**
* @typedef {object} SlugOptions
* @property {string} [separator] Put between words. Default "-".
* @property {number} [maxLength] Cut the slug to this many characters.
*/
/**
* Turn a title into a URL slug.
* @param {string} title
* @param {SlugOptions} [options]
* @returns {string}
*/
export function slugify(title, options = {}) {
const { separator = "-", maxLength = Infinity } = options;
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, separator)
.replace(new RegExp(`^${separator}|${separator}$`, "g"), "")
.slice(0, maxLength);
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"module": "node18",
"target": "es2022",
"types": [],
"skipLibCheck": true
},
"include": [
"src/**/*.js"
]
} Under TypeScript 6.0 and 7.0
Every example on this page also runs under TypeScript 6.0.3 and 7.0.2 when this site is built. One of them reports something different from 5.9.3, the version the page describes; the ✗ on its badge links to its entry below.
rootDir defaults to the config's folder
The pattern: A package that writes .d.ts files from src/ with declarationDir and no rootDir.
Why: Only the 6.0 default for rootDir causes this. 5.9 took the folder that holds every source file (src/), so src/price.js became types/price.d.ts. From 6.0 the default is the folder of the config, so the same file becomes types/src/price.d.ts, and tsc reports TS5011 asking for an explicit rootDir. The .d.ts that 6.0.3 writes is otherwise the same file. Source: TypeScript 6.0 release notes: rootDir now defaults to ..
The package source: plain JavaScript with JSDoc src/index.js
- TypeScript 5.9.3
No errors.
Writes the .d.ts files the example shows.
- TypeScript 6.0.3differs from 5.9.3
error TS5011: The common source directory of 'jsconfig.types.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout. Visit https://aka.ms/ts6 for migration information.
The file it writes:
types/src/index.d.ts/** * @typedef {object} SlugOptions * @property {string} [separator] Put between words. Default "-". * @property {number} [maxLength] Cut the slug to this many characters. */ /** * Turn a title into a URL slug. * @param {string} title * @param {SlugOptions} [options] * @returns {string} */ export function slugify(title: string, options?: SlugOptions): string; export type SlugOptions = { /** * Put between words. Default "-". */ separator?: string | undefined; /** * Cut the slug to this many characters. */ maxLength?: number | undefined; };- TypeScript 7.0.2differs from 5.9.3
jsconfig.types.json(7,5): error TS5011: The common source directory of 'jsconfig.types.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout. Visit https://aka.ms/ts6 for migration information.
The file it writes:
types/src/index.d.ts/** * @typedef {object} SlugOptions * @property {string} [separator] Put between words. Default "-". * @property {number} [maxLength] Cut the slug to this many characters. */ export type SlugOptions = { /** * Put between words. Default "-". */ separator?: string; /** * Cut the slug to this many characters. */ maxLength?: number; }; /** * Turn a title into a URL slug. * @param {string} title * @param {SlugOptions} [options] * @returns {string} */ export declare function slugify(title: string, options?: SlugOptions): string;
What works in every version: Set "rootDir": "src" in jsconfig.types.json, which gives the 5.9 layout in every version.
7.0 writes different .d.ts files from JavaScript
The pattern: A package that writes its .d.ts files from JSDoc with declaration and emitDeclarationOnly.
Why: The port rewrote declaration output for JavaScript, and its list of changes says matching the old output exactly is not a goal. With rootDir set to src, TypeScript 7.0.2 still writes different files from 5.9's: a @typedef becomes an export type where its comment is (5.9 puts it last), functions and classes are export declare, an exported object is a const rather than a namespace, and optional properties lose their | undefined. Source: CHANGES.md: Declaration Emit.
The package source: plain JavaScript with JSDoc src/index.js
- TypeScript 5.9.3
No errors.
Writes the .d.ts files the example shows.
- TypeScript 6.0.3differs from 5.9.3
error TS5011: The common source directory of 'jsconfig.types.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout. Visit https://aka.ms/ts6 for migration information.
The file it writes:
types/src/index.d.ts/** * @typedef {object} SlugOptions * @property {string} [separator] Put between words. Default "-". * @property {number} [maxLength] Cut the slug to this many characters. */ /** * Turn a title into a URL slug. * @param {string} title * @param {SlugOptions} [options] * @returns {string} */ export function slugify(title: string, options?: SlugOptions): string; export type SlugOptions = { /** * Put between words. Default "-". */ separator?: string | undefined; /** * Cut the slug to this many characters. */ maxLength?: number | undefined; };- TypeScript 7.0.2differs from 5.9.3
jsconfig.types.json(7,5): error TS5011: The common source directory of 'jsconfig.types.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout. Visit https://aka.ms/ts6 for migration information.
The file it writes:
types/src/index.d.ts/** * @typedef {object} SlugOptions * @property {string} [separator] Put between words. Default "-". * @property {number} [maxLength] Cut the slug to this many characters. */ export type SlugOptions = { /** * Put between words. Default "-". */ separator?: string; /** * Cut the slug to this many characters. */ maxLength?: number; }; /** * Turn a title into a URL slug. * @param {string} title * @param {SlugOptions} [options] * @returns {string} */ export declare function slugify(title: string, options?: SlugOptions): string;
What works in every version: Compare the .d.ts files before releasing with 7.0, and commit the new ones once they read right.
In your editor
VS Code reads this jsconfig.json with no further setup. To make it use the same TypeScript as npm run lint, see VS Code.
Editors use jsconfig.json, so while you work they check your source and never write files. Only npm run types writes the .d.ts files.
Sources
- Creating .d.ts Files from .js files · TypeScript handbook
- Modules: Choosing Compiler Options · TypeScript handbook
- TypeScript 4.7 release notes · TypeScript handbook
- TSConfig Reference: declaration · TypeScript
- Modules: Packages, conditional exports · Node.js docs
Last reviewed 20 October 2026.