Skip to content
teach

Lesson 44. Publishing a Type Surface

Mission link: Owning a codebase eventually means shipping part of it to someone else, and this lesson turns that responsibility into three things you can actually run: generate the declarations, resolve them from a consumer, and read what crossed the boundary.
Primary source: Handbook, Declaration Files
Prerequisites: Lesson 34, Lesson 21

Warm-up

  1. ▢ Lesson 21 showed that identical import text gets opposite verdicts depending only on one field in package.json, with the compiler and Node's resolver working from the same rules. Lesson 34 showed that a generated declaration cannot disagree with the implementation it came from, unlike a hand written one. Put those two together: once you publish a package, what exactly is a consumer's compiler resolving, and whose settings decided what it contains?
Check

A consumer's compiler resolves a .d.ts file, found through the same module resolution lesson 21 described, now applied to fields the author writes in package.json rather than to a specifier the consumer writes. What that file contains was decided entirely by the author's build: generated, per lesson 34, from the author's own implementation, under the author's own compiler settings. The consumer's own settings play no part in either finding it or in what it says.

Know this

Generating the declarations rather than writing them

Two compiler options turn an implementation into the file a consumer actually reads. declaration asks the compiler to emit a .d.ts alongside each output, and emitDeclarationOnly asks it to emit nothing else, no .js at all, which suits a project where a separate build step already produces the JavaScript and this pass of tsc has only the types to do. A small library exporting a function and an interface, compiled this way, produces exactly this, unedited:

export declare function find(k: string): string | undefined;
export interface Opts {
    retries?: number;
}

Lesson 34 already made the case for why this beats writing the file by hand: a generated declaration is copied out of the implementation's own signatures, so it cannot disagree with what the function actually does, where a hand written one for code nobody else can inspect is an unaudited promise. Publishing raises the stakes on that fact rather than changing it. A hand written .d.ts shipped alongside your own compiled output is a second copy of the truth that nothing keeps in step with the first, and every later edit to the implementation is a chance for the two to drift apart; a generated one is regenerated by the same build, so that particular kind of drift cannot happen at all. It can still be wrong in one way generation does not fix: the implementation's own types can be internally consistent while being wrong about what the code actually returns, and whether that happened depends on the author's own compiler settings, which is lesson 45's territory rather than this one.

How a consumer finds them

A package advertises where its types live through the same fields lesson 21 already taught you to read, aimed at a different question than that lesson asked. The older field is types, a single path naming the one declaration file that describes the whole package, written "types": "./dist/index.d.ts". A package using the newer exports map, which restricts what a consumer may import to exactly the subpaths it lists, repeats the same information per subpath as a types condition sitting alongside default:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  }
}

Both point at a file the way a specifier points at a file, resolved by exactly the module resolution machinery lesson 21 covered rather than by a separate publishing system. Built as a real two package project, a library exporting two functions behind an exports map with an explicit types condition, and a consumer importing both and assigning the results, type checks with no diagnostic at all.

The field is not decoration, though the compiler is more forgiving about a missing one than you might expect. Leave types out entirely, with no exports map either, and resolution still finds a .d.ts sitting next to whatever main names, the same kind of extension based fallback lesson 21 covered for an ordinary specifier that omits one. The field only starts to matter once nothing sits at the place the fallback would look. Suppose a build step tidies its output and moves the declaration file without updating the pointer: dist/index.d.ts becomes dist/types/index.d.ts, and the types condition in exports still names the old path. The fallback tries the file beside default's target first and finds nothing there either, since the real file has moved, and the consumer's compiler reports:

error TS7016: Could not find a declaration file for module 'mylib'. '.../node_modules/mylib/dist/index.js' implicitly has an 'any' type.
  Try `npm i --save-dev @types/mylib` if it exists or add a new declaration (.d.ts) file containing `declare module 'mylib';`

the identical diagnostic lesson 34 met for a dependency that never shipped declarations at all. From the consumer's side, a wrong path and no path look exactly the same. Point the types condition at the file's real location and the same import resolves and type checks clean again.

What the surface actually includes

A public function's signature drags in everything mentioned inside it, whether or not the author meant to publish that part. Add a second function to the library above, taking an options object shaped by an interface the author never exported:

interface RetryConfig {
  retries: number;
  backoffMs: number;
}

export function configure(cfg: RetryConfig): void {
  // ...
}

The generated declaration keeps RetryConfig exactly as unexported as the source did, but keeps it in the file all the same, because configure's signature needs it there to type check anything:

interface RetryConfig {
    retries: number;
    backoffMs: number;
}
export declare function configure(cfg: RetryConfig): void;

A consumer calling configure({ retries: 3, backoffMs: 100 }) gets full checking against that shape: an extra or misspelled property is rejected exactly as against any other interface. But naming the type directly fails. import { RetryConfig } from "mylib"; reports error TS2459: Module '"mylib"' declares 'RetryConfig' locally, but it is not exported. The shape is public, sitting in the file every consumer's compiler reads; only the name that would let someone write an annotation with it is missing. That is lesson 43's rename seen from the other side: an import resolves by name and nothing else, which is why renaming a type of identical shape broke a consumer there, and why a perfectly public shape is unimportable here. Not exporting RetryConfig did not keep it private, it only removed one convenient way of referring to a type that was already part of the contract the moment an exported signature mentioned it. Read your own generated .d.ts the way a consumer will, rather than as build output nobody looks at; if a type you consider internal shows up there, it already is not internal, whatever you called it or left unexported.

Checking it before a consumer does

Three checks turn this lesson into something you can run against your own package before anyone else does. First, generate the declarations: run the compiler with declaration and emitDeclarationOnly set, or with declaration alone if the same pass should also produce the JavaScript, and open the file it writes rather than assuming it wrote what you meant. Second, resolve them from a consumer built the way one really is: a separate package with its own package.json and tsconfig.json, the library installed the ordinary way rather than referenced by a relative path into your source tree, importing exactly the names a real consumer would import. Third, read the generated .d.ts itself from top to bottom, asking of every exported function whether every type its signature mentions is a primitive, something already exported by name, or something you are content to have published without a name attached to it. A clean compile in the consumer answers whether resolution works at all; reading the file is the only one of the three that catches a surface wider than intended, since a clean compile happens identically whether or not that surface was ever a decision anyone made.

Practice

  1. ▢ A library's source is export function toKey(id: number): string { return String(id); } followed by export interface Query { limit?: number; }, compiled with declaration: true and emitDeclarationOnly: true. Predict the exact .d.ts produced.
Check
export declare function toKey(id: number): string;
export interface Query {
    limit?: number;
}
  1. ▢ A library's package.json has "main": "./dist/index.js" and no types field and no exports field at all. dist/index.d.ts sits in the same folder, generated by the same build. Predict whether a consumer importing the package resolves its types.
Check

Yes. With no explicit types field, resolution falls back to a declaration file with the same name as the one main points at, in the same folder, the same kind of extension based fallback lesson 21 covered for an ordinary specifier missing its extension. Resolution only fails once no file answers either an explicit field or this fallback.

  1. ▢ A team moves their build output from dist/index.d.ts to dist/types/index.d.ts to tidy the folder, and forgets to update the types condition in their exports map, which still says ./dist/index.d.ts. Predict what a consumer's tsc reports, with its number.
Hint

Ask whether the fallback that rescued item 2 can rescue this one too, given where the real file now sits relative to where default still points.

Check

error TS7016: Could not find a declaration file for module 'mylib'. '.../dist/index.js' implicitly has an 'any' type., with the same two suggestions lesson 34's version of this diagnostic gave. The fallback in item 2 only helps when a matching .d.ts sits beside the file default names; here it does not, since the real file moved to a different folder, so both the explicit path and the fallback come up empty.

  1. ▢ A public function save(record: Record<string, unknown>, opts: SaveOptions): void is exported, where SaveOptions is declared in the same file but never given its own export. Predict two things: whether SaveOptions appears in the generated .d.ts, and whether import { SaveOptions } from "the-package" compiles for a consumer.
Check

It appears in the .d.ts, unexported, because save's signature needs it there to type check. Importing it by name still fails: error TS2459: Module declares 'SaveOptions' locally, but it is not exported. The type's shape became part of the public surface the moment an exported signature mentioned it; only the name to refer to it directly is missing.

  1. ▢ Before publishing, a reader runs tsc with declaration and emitDeclarationOnly, gets a clean run with no errors, and stops there, satisfied the package is ready. What has this check actually confirmed, and what has it left unconfirmed?
Check

It confirms only that the compiler could turn the implementation into declarations at all, which fails only if the source itself does not type check. It confirms nothing about whether a consumer can resolve those declarations, which depends on fields in package.json a compile error never touches, and nothing about whether the surface those declarations expose is the one the author intended, since an internal type dragged in by a public signature compiles exactly as cleanly as one deliberately exported. Both remaining checks have to be run separately: against a real consumer package, and against the generated file itself.

Real-world reps

  • [ ] Generate the declarations for a package you maintain, or one you have source access to, and open the resulting .d.ts before opening anything else the build produced.
  • [ ] Find a types field or an exports map with a types condition in a package.json you depend on, and trace by hand which file it names and whether that file actually exists at that path.
  • [ ] Tomorrow: pick one exported function from a package you maintain and check whether every type its signature mentions is either exported by name or something you are content to have published without one.

Going further


Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.

If the lesson itself is unclear rather than the material, that is a defect: open an issue.

Table of contents