A JavaScript package can give its users the same types a TypeScript package does, without a line of TypeScript. tsc reads the JSDoc in your .js files and writes .d.ts files from it; package.json tells your users’ tools where those files are. This guide is the full story behind the library quick start and the publishing recipe: writing the files, pointing at them, checking the result as a consumer would, and the details that trip packages up.
1. Type the source with JSDoc
The types your users see are the types tsc reads in your source, so the exported functions need @param and @returns types and the shapes they take need @typedefs. Descriptions in the comments travel into the .d.ts too, which is how editors show them on hover in your users’ code.
/**
* @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"
]
} Check the source with strict on. A parameter without a type would otherwise reach your users as any, and nothing would tell you.
2. Write the .d.ts files with a second config
Keep jsconfig.json for checking (it has noEmit: true), and add a config that writes declaration files and nothing else:
{
"extends": "./jsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"declarationDir": "types"
}
} noEmit: false is the line people miss. A config that extends a jsconfig.json inherits its file-name defaults, noEmit: true included, and noEmit silently wins over emitDeclarationOnly: no error, and no files. declarationDir keeps the output in its own folder, out of your sources. Run it with tsc -p jsconfig.types.json; for the source above it writes:
/**
* @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"
}
} This site’s build runs that config for the example and fails if the file it writes differs from the one shown, so the output above is what TypeScript 5.9 writes today. Add declarationMap: true if you want “go to definition” in your users’ editors to land in your .js source instead of the .d.ts; declaration covers the options.
3. Point package.json at the types
Users’ tools find the types through exports. Give each entry a types condition, first, and make sure the files are published:
{
"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"
}
} typescomes first in each entry, because conditions are matched in order anddefaultmatches everything.filesincludes the types folder. Otherwise the published package has atypescondition that points at nothing.prepackwrites the types beforenpm packandnpm publish, so the tarball always has fresh ones and the repository needs none.- Every subpath needs its own
types. An export such as"./format"gets its own condition, or a.d.tsfile next to its.jsfile.
A top-level "types" field is still read by tools that ignore exports (moduleResolution: "node10" among them), so many packages keep it, pointing at the same file. It is not enough on its own once exports exists:
import { formatPrice } from "@acme/price-tools";error TS7016: Could not find a declaration file for module '@acme/price-tools'. '/project/node_modules/@acme/price-tools/src/index.js' implicitly has an 'any' type.
There are types at '/project/node_modules/@acme/price-tools/types/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@acme/price-tools' library may need to update its package.json or typings.
console.log(formatPrice(1999));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [],
"maxNodeModuleJsDepth": 0
},
"include": ["*.js"]
} 4. Check the package as a consumer
The most reliable test is the one your users run: install the package in a small project and type-check code that uses it. Pack it first, so you test exactly what will be published:
npm pack
mkdir ../consumer && cd ../consumer
npm init -y && npm pkg set type=module
npm install --save-dev typescript ../tiny-slug/tiny-slug-1.0.0.tgzGive the consumer a jsconfig.json that makes the test strict: maxNodeModuleJsDepth: 0, so tsc cannot fall back to reading the package’s JavaScript, and skipLibCheck: false, so it checks the .d.ts files themselves. A consumer that misuses the package then fails with your types’ error, and a broken .d.ts fails too:
import { formatPrice } from "@acme/price-tools";
console.log(formatPrice(1999));
console.log(formatPrice("19.99"));error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [],
"maxNodeModuleJsDepth": 0,
"skipLibCheck": false
},
"include": ["**/*.js"]
} Run the consumer check with each TypeScript version you support (npx -p typescript@5.5 tsc -p jsconfig.json), because a .d.ts written by a newer TypeScript can use syntax an older one cannot read.
5. Let tools check the resolution rules
Two community tools automate the parts that are easy to get wrong. Run them on the packed package before each release; both are described here per their own documentation:
npx --yes @arethetypeswrong/cli --pack .
npx publint- Are the types wrong? (
attw) packs the package and resolves every entry point the way TypeScript does under each resolution mode (node10,node16from CommonJS and from ES modules, andbundler), then reports entries whose types are missing or disagree with the JavaScript.--profile node16ignoresnode10, and--profile esm-onlyalso ignores CommonJS consumers, for packages that do not support them. - publint lints
package.jsonagainst what Node.js and bundlers expect: the order of conditions, files thatexportsnames but the package does not include, and a format that does not match the file’s extension.
CommonJS consumers
An ES module package, like the one above, works for CommonJS users in two cases. Per the Node.js documentation, recent versions of Node.js can require() an ES module that has no top-level await, and TypeScript 5.8 and later accept the same require under module: nodenext (TypeScript 5.8):
const { add } = require("./math.mjs");
console.log(add(1, 2));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": [
"*.cjs",
"*.mjs"
]
} Under module: node16, or for users on Node.js versions without that support, the same require is TS1479. Serving them means publishing a CommonJS build next to the ES module one, with a require condition in exports and its own types file in the matching format (.d.cts for a .cjs file), per TypeScript’s modules reference. Decide whether you need that before you publish an ESM-only major version, and say so in your README; attw --profile esm-only then stops reporting it.
Versioning the types
Your .d.ts files are part of your API. A change that makes a parameter type narrower, a return type wider or an options property required breaks your users’ type check even when the JavaScript still works, so treat it like any other breaking change. Three habits keep that visible:
- Review the
.d.tsoutput. Printgit diff --no-indexof the old and newtypes/folders in CI, or commit them, so a type change shows up in the pull request that caused it. - Mark what you are about to remove with
@deprecated. It travels into the.d.ts, and your users’ editors strike the name through. - Re-check after upgrading TypeScript. The
.d.tsoutput can change between versions, and TypeScript 7.0’s notes say its output from JavaScript files will differ from 6.0’s (TypeScript 7.0). Compare the files before releasing with a new major.
Every example on this page was checked with TypeScript 5.9.
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.
Questions
Do I have to commit the .d.ts files?
No. Most packages write them in a prepack script, so they are always fresh in the tarball and never in the repository. Committing them, or printing a diff of them in CI, makes type changes visible in review, which is worth it for a widely used package.
Can I publish types without writing JSDoc for everything?
Yes. tsc writes what it can infer, and an exported function without a @param type gets any for that parameter in the .d.ts. With strict on, tsc reports those parameters before you publish, which is the better default.
My package is ESM only. Can CommonJS projects use its types?
Under module: nodenext, TypeScript 5.8 and later accept require() of an ES module, as recent Node.js versions do. Under node16, or for older Node.js versions, they need a CommonJS build with its own types.
Sources
- Creating .d.ts Files from .js files · TypeScript handbook
- Modules Reference: packages with exports · TypeScript handbook
- Publishing · TypeScript handbook
- Are the types wrong? (CLI) · GitHub
- publint CLI · publint
- Modules: loading ECMAScript modules using require() · Node.js documentation
Last reviewed 21 October 2026.