Guide · Packages · 21 October 2026 · 10 min read

Ship types from a JavaScript package

Publish a JavaScript package with types: write .d.ts files from JSDoc, point exports at them, check them as a consumer, and keep CommonJS users working.

  • Examples checked with TypeScript 5.9

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.

src/index.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. Writes the .d.ts files shown. 6.0.3 : differs, see why TypeScript 6.0.3, run 21 October 2026: differs 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. Writes different .d.ts files. 7.0.2 : differs, see why TypeScript 7.0.2, run 21 October 2026: differs 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. Writes different .d.ts files. checked 21 October 2026
/**
 * @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);
}
The package source: plain JavaScript with JSDoc.
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:

jsconfig.types.json
{
  "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:

types/index.d.ts Written by TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. Writes the .d.ts files shown. 6.0.3 : differs, see why TypeScript 6.0.3, run 21 October 2026: differs 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. Writes different .d.ts files. 7.0.2 : differs, see why TypeScript 7.0.2, run 21 October 2026: differs 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. Writes different .d.ts files. checked 21 October 2026
/**
 * @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;
};
types/index.d.ts, as tsc writes it from the JavaScript.
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:

package.json
{
  "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"
  }
}
  • types comes first in each entry, because conditions are matched in order and default matches everything.
  • files includes the types folder. Otherwise the published package has a types condition that points at nothing.
  • prepack writes the types before npm pack and npm 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.ts file next to its .js file.

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:

app.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: app.js(2,29): 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. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. app.js(2,29): 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. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. app.js(2,29): 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. checked 21 October 2026
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));
The same package with types that exports does not lead to.
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.tgz

Give 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:

app.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: app.js(5,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. app.js(5,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. app.js(5,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. checked 21 October 2026
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'.
A consumer that reads only the package's .d.ts, and checks it.
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, node16 from CommonJS and from ES modules, and bundler), then reports entries whose types are missing or disagree with the JavaScript. --profile node16 ignores node10, and --profile esm-only also ignores CommonJS consumers, for packages that do not support them.
  • publint lints package.json against what Node.js and bundlers expect: the order of conditions, files that exports names 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):

main.cjs TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
const { add } = require("./math.mjs");

console.log(add(1, 2));
A CommonJS file that require()s an ES module, under nodenext.
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.ts output. Print git diff --no-index of the old and new types/ 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.ts output 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

TypeScript 6.0 and 7.0 · defaults that changed in 6.0

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

TypeScript 7.0 · the .d.ts files written 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

Last reviewed 21 October 2026.