Language Internals
Part 6 of 6 · JS/TS Language ProficiencyModules, Emit, Strictness, Declaration Files & Tooling
ESM/CJS, tsconfig emit, strictness, declaration files, package exports.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What does a .d.ts file contain?
Answer
Declarations only. No runtime. Consumers' compilers read it. The JS file beside it is what Node or the browser runs.
L2
When should a library use tsc to emit?
Answer
When you need per-file JavaScript plus declarations that match the source. Apps often typecheck with tsc --noEmit and let a bundler emit.
L3
What does strict turn on?
Answer
An umbrella: strictNullChecks and the other strict family. Libraries should ship with it on.
L4
Why does NodeNext require a .js extension in an import written in TypeScript?
Answer
The specifier is what the runtime will load after emit. TypeScript does not invent an extension Node will refuse.
L5
What does exports.types do?
Answer
It tells a consumer's resolver which declaration file matches that entrypoint. A wrong path yields any or a failed lookup.
L6
Is skipLibCheck safe?
Answer
It skips checking declaration files for speed. Fine for an app on known dependencies. Risky when you author or patch the typings.
L7
What is the dual-package hazard?
Answer
Shipping ESM and CJS builds that are not the same module instance, or exports conditions that point types at one build and Node at another.
Failure modes
types condition points at the wrong file
JavaScript loads. The editor shows any, or a declaration from a different build.
NodeNext import without an extension
The compiler rewrites nothing the runtime needs. Node throws ERR_MODULE_NOT_FOUND.
strict off at the boundary
Null and implicit any leave the package and become the caller's production bug.
path aliases with no runtime map
tsc or the editor resolves @/lib. Node and the published package do not, unless a bundler or exports map mirrors it.
require of an ES module
CJS require of an ESM file fails. The supported direction is a dynamic import.
Misconceptions
The bundler replaces the need for a module setting.
The bundler emits app JavaScript. module and moduleResolution still decide what the checker accepts and what a library emits.
types at the top of package.json is enough.
exports can hide that field. Each entrypoint needs a types condition or consumers resolve the wrong file.
skipLibCheck means the program typechecked.
It means your files were checked and dependency declarations were skipped.
Interviewer traps
Listing every tsconfig flag you have ever seen.
Name strict, module and moduleResolution, declaration, and exports. Then stop until they ask.
Saying ESM and CJS are interchangeable with esModuleInterop.
esModuleInterop helps default-import a CJS module. It does not make require load ESM.
Treating an app's bundler config as a library publish plan.
Apps can noEmit. Libraries need declarations and a tested exports map.
Design scenario
Same prompt for every reader.
Requirements
Pick module and moduleResolution, say what tsc must emit, and write the exports conditions that keep types and JavaScript together.
Traffic / scale
Publish-time, not request-time. Every downstream compile is a consumer.
Latency
The failure shows up at install and first import, not at p99.
Consistency
The file Node loads and the file the checker reads must describe the same entrypoint.
Availability
A bad publish should fail your CI before it fails a caller's build. arethetypeswrong is one such check.
Failure assumptions
- Consumers use Node ESM.
- Some callers still use a bundler.
- A types path can be wrong while the JS path is right.
Constraints
- Stay on emit, resolution, and package exports.
- Do not redesign the library's domain types. That was the previous page.
Prompt
You publish a small library. Consumers on Node see ERR_MODULE_NOT_FOUND, and their editors say the default export is any.
Who emits JavaScript?
Prefer
tsc for a library
Per-file emit plus declaration and declarationMap. Consumers typecheck against you without running your bundler.
- The .d.ts matches the source the compiler saw.
- NodeNext or the real runtime setting is explicit.
- exports lists types and import together.
Alternative
Bundler for an app
esbuild, swc, webpack, or rollup emit the app. tsc --noEmit only typechecks. You usually do not publish a .d.ts.
- Faster builds, one graph.
- Path aliases can exist only in the bundler.
- A library published this way often forgets declarations.
From a TypeScript file to two artifacts
The checker and the runtime do not read the same file.
- 1
Author ES module syntax
import and export in the source, even when a consumer is still on CJS interop. - 2
Typecheck
strict, module, and moduleResolution. This can be tsc --noEmit in an app. - 3
Emit JavaScript
tsc for a library, or the bundler for an app. target and lib decide syntax and globals. - 4
Emit declarations
declaration and declarationMap for anything you publish. Wire them through exports.
Overview
TypeScript is a compiler with product settings. module, moduleResolution, target, strict, and declaration emit decide what leaves the package. A misaligned ESM and CJS build, a broken .d.ts, or strict turned off to ship are library landmines. This page is tooling and contracts. Framework presets are out of scope.
Two different tools may be involved. A bundler can emit JavaScript while tsc --noEmit only typechecks. That split is healthy for an application and incomplete for a library, because the library's consumers need declarations.
Two artifacts
Flow
- 1
1. Source TypeScript
- next2. tsc or a bundler
- 2
2. tsc or a bundler
- next3. Emitted JavaScript
- next4. Emitted declarations
- 3
3. Emitted JavaScript
- next6. Node or the browser
- 4
4. Emitted declarations
- next5. Consumer typecheck
- 5
5. Consumer typecheck
- 6
6. Node or the browser
Lesson map
Modules, Emit, Strictness, Declaration Files & Tooling
ESM/CJS, tsconfig emit, strictness, declaration files, package exports.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB src["1. Source TypeScript"] tsc["2. tsc or a bundler"] js["3. Emitted JavaScript"] dts["4. Emitted declarations"] src -->|1. Source TypeScript| tsc tsc -->|2. tsc or a bundler| js tsc -->|2. tsc or a bundler| dts
Authoring uses import and export. Interop with CommonJS is a compatibility layer, not a second language in your source. Shipping both an ESM build and a CJS build means package.json exports must send each consumer to the matching file. Two copies of a module (one ESM, one CJS) are a classic way to get two copies of a singleton.
tsc, bundler, or project references
| Approach | Types | JavaScript | Best for |
|---|---|---|---|
tsc emit | First-class .d.ts | Per file, close to source | Libraries |
Bundler plus tsc --noEmit | Check only | The bundler's graph | Applications |
| Project references | Incremental .tsbuildinfo | Monorepo build graph | Large workspaces |
composite and references are how a monorepo typechecks a package against its dependency's declarations instead of against that package's source. They pay off when tsc on the whole repo stops fitting in CI.
Flags that change the contract
| Option | What it decides |
|---|---|
strict | Umbrella, including null checks. On for libraries |
noUncheckedIndexedAccess | An index read may be undefined |
exactOptionalPropertyTypes | A missing property is different from a property set to undefined |
module and moduleResolution | NodeNext or Bundler, chosen to match the loader |
target and lib | Downlevel syntax, and which globals exist |
declaration and declarationMap | Publish typings and make go-to-definition land in your source |
composite and references | Project references |
skipLibCheck | Faster CI. Skips errors inside dependency .d.ts files |
verbatimModuleSyntax | Type-only imports stay marked, so a value import is obviously a value |
strict is the switch you defend in an interview. The narrower flags are how you answer "what does strict still miss?" Indexed access and optional properties are the usual follow-ups. strictFunctionTypes belongs with the callback variance note on the type system page.
Declaration files
- Generated from your TypeScript when
declarationis true. This is the default for a library you wrote in TypeScript. - Hand-written ambient modules for JavaScript that has no types (
declare module "legacy-sdk"). @typespackages from DefinitelyTyped, for JavaScript libraries other people published.typesandtypeRootscontrol which global declaration packages are included. A widetypeRootscan inject globals you did not import.
declare module "legacy-sdk" {
export function connect(opts: { url: string }): Promise<void>;
}Ambient modules are a boundary. They are easy to make lie, because nothing checks them against the JavaScript until a consumer calls it.
Exports checklist
The comments are the package layout. The function is the runtime you can run here. A real publish still needs the files on disk. This sandbox only checks that the conditions you would write are non-empty and paired.
/**
* package.json (illustrative):
* {
* "type": "module",
* "exports": {
* ".": {
* "types": "./dist/index.d.ts",
* "import": "./dist/index.js"
* }
* },
* "types": "./dist/index.d.ts"
* }
*
* tsconfig for a library:
* strict, module NodeNext, moduleResolution NodeNext,
* declaration true, outDir dist, rootDir src
*/
export function add(a: number, b: number): number {
return a + b;
}export is legal in a module and illegal inside the page runner, so the runnable keeps the same add and checks the exports map as data.
Press Run. Snippets must be self-contained — no network, files, or native modules.
exports wins over the top-level types field for entrypoints it lists. If types is missing inside the condition, resolvers can pick a JavaScript file and give up on types.
ESM and CJS traps
- Default import of CJS.
esModuleInteropandallowSyntheticDefaultImportslet you write a default import againstmodule.exports. They do not change what Node does at runtime by themselves. The emit has to match. - Extensions under NodeNext. In TypeScript source you write
import "./x.js"so the emitted specifier is one Node can load. The file on disk may still bex.ts. requireof ESM. It fails. Load ESM with a dynamicimport(), which returns a promise and therefore rejoins the event loop as a microtask when it settles.pathsaliases. The compiler can rewrite a type-only view of@/lib. The runtime needs a bundler or a realexportsmap. Publishingpathsas if they were package names breaks consumers.
Decisions
- 1
1. import specifier
- next2. NodeNext or bundler?
- ?
2. NodeNext or bundler?
- node3. Specifier ends in .js
- bundle4. Bundler may rewrite paths
- 3
3. Specifier ends in .js
- next5. package exports
- 4
4. Bundler may rewrite paths
- next5. package exports
- 5
5. package exports
- next6. types and import agree
- 6
6. types and import agree
Interview Q&A
Why do library authors care about declaration and exports.types?
Answer
Consumers resolve types through package.json conditions. A wrong path yields any or a broken go-to-definition even when the JavaScript runs. declaration: true generates the file. exports has to point at it.
Is skipLibCheck cheating?
Answer
It skips typechecking of declaration files so CI is faster. That is reasonable for an application on dependencies you trust. It is risky when you write or patch those declarations, because the errors you care about are inside them.
How do you handle strictness under delivery pressure?
Answer
Turn strict on at boundaries you publish: the public API and the domain model. Use unknown and narrow it. Stage strictNullChecks if you must migrate. A global any is a larger delay than the migration, because every caller inherits it.
tsc emit or a bundler?
Answer
Libraries: tsc, with declarations. Applications: the bundler emits, tsc --noEmit checks. Project references when the monorepo outgrows one compilation. Do not publish an app bundler's output and call it a typed package unless you also shipped matching .d.ts files.
Why does an import of ./file.js appear in a .ts file?
Answer
Under NodeNext, the specifier is the runtime specifier. TypeScript resolves file.js to file.ts while you edit, and Node loads file.js after emit. Omitting the extension typechecks only under looser resolution and fails in Node ESM.
What does esModuleInterop not fix?
Answer
Loading an ES module with require. It also does not repair an exports map that points types at a different build than import. Interop is for calling CJS from ESM-shaped source, not for pretending the module systems are one system.
Where do path aliases bite?
Answer
In any tool that was not given the same map. The editor follows paths. Node follows exports and relative specifiers. A library that imports itself through an alias publishes source that consumers cannot resolve.
What do you say in the first minute?
Answer
Two artifacts, JavaScript and .d.ts. strict on for anything you publish. module matches Node or the bundler. Libraries emit with tsc. Apps can noEmit. exports must list types next to import. NodeNext means .js specifiers. require does not load ESM.
Pitfalls
- Publishing JavaScript with no
typescondition. - Turning
strictoff in a library and calling the types done. - Using
skipLibCheckto hide a declaration you just wrote. - Importing
./fileunder NodeNext. - Mirroring
pathsinto published code. - Maintaining ESM and CJS builds that do not share one
exportsmap. - Expecting the type system to survive as runtime checks. It will not. This page is only how the erased types are delivered.
For a package with "type": "module", write the exports entry for "." with types and import. Then list the tsconfig keys you need so dist/index.d.ts exists and Node can load dist/index.js. Say which one failure mode is a runtime error and which one is any in the editor.