Skip to content

Instantly share code, notes, and snippets.

@bsitruk
Created July 2, 2025 09:10
Show Gist options
  • Save bsitruk/a4fabf795d493440786ace9e734af37a to your computer and use it in GitHub Desktop.
Save bsitruk/a4fabf795d493440786ace9e734af37a to your computer and use it in GitHub Desktop.

TSConfig customConditions & Conditional Exports

Below is a practical, end-to-end guide to **`compilerOptions.customConditions`** in *tsconfig.json*—what it is, when to reach for it, and how to wire everything up so that your editor, the TypeScript compiler, your bundler, and Node itself all resolve the same files.

1. What customConditions actually does

customConditions lets you tell the TypeScript module resolver to recognise extra [conditional-export “conditions”] beyond the built-ins (import, require, node, default, etc.). At compile-time, tsc will use those additional keys whenever it looks at a package’s "exports" or "imports" map, exactly the same way Node or modern bundlers would at run-time.(typescriptlang.org)

Why bother? If a package (maybe your package) publishes environment-specific bundles—"browser", "worker", "development", "production", "types", and so on—you want your editor/IDE hover info, jump-to-definition, and build output to follow the same branch that your runtime will use. That is precisely what customConditions unlocks.


2. Preconditions & compatibility matrix

Where it matters Minimum version / flag Notes
TypeScript v5.0 – add "customConditions" Option is only honoured when moduleResolution is "node16", "nodenext" or "bundler".(typescriptlang.org)
Node runtime v14.9.0 – --conditions flag for user conditions Node already resolves built-ins. You only need --conditions=<name> for custom ones.(nodejs.org)
Bundlers Webpack 5, Vite, Rollup 3 They expose resolve.conditionNames/resolve.conditions or similar; list the same strings you put in customConditions.

3. Minimal working example

3.1 Library’s package.json

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "production": "./dist/prod.mjs",
      "development": "./dist/dev.mjs",
      "default": "./dist/prod.mjs"
    }
  }
}

3.2 App’s tsconfig.json

{
  "compilerOptions": {
    "module": "esnext",
    "moduleResolution": "bundler",
    "target": "es2022",
    "customConditions": ["development"]   // adds to (import, require, node…)
  }
}

Result: tsc (and therefore your IDE) will resolve my-lib/dist/dev.mjs. Swap "development" for "production" and the prod build is used instead.


4. Step-by-step cookbook

Step What to do Why
1. Pick a resolver Set "moduleResolution" to "node16", "nodenext" or "bundler". Classic resolution cannot look at "exports"/"imports" at all.
2. List your extra conditions "customConditions": ["browser", "development"] Order does not matter; they’re appended to Node’s default list.
3. Mirror it in your bundler Webpack: resolve.conditionNames: ["browser","development"] Keeps dev-server behaviour identical to tsc.
4. (Optional) pass flags to Node node --conditions=development app.js Needed only if the runtime should match a non-default condition.
5. Always include a "default" branch in packages you publish Fallback guarantees third-party tools that ignore your condition still work.(nodejs.org)

5. Advanced patterns

5.1 Dual Node/Browser builds

{
  "compilerOptions": {
    "moduleResolution": "node16",
    "customConditions": ["browser"]
  }
}
import "foo" in… File chosen (foo’s exports)
Tests run with ts-node "node"./dist/node.cjs
Front-end code (IDE view + Vite dev server) "browser"./dist/browser.mjs

5.2 “Types-only” condition for declaration files

Many packages now publish a "types" condition (first in the object) so build tools can grab *.d.ts without pulling JS. Add it to customConditions to silence “Cannot find module ‘…’ ” when authoring libraries with separate type bundles.

5.3 Per-environment dead-code elimination

Because tsc resolves the actual file path, roll-up-style bundlers can now tree-shake entire environment branches away. Mark debug-only helpers behind a "development" condition and they vanish from the production bundle automatically.


6. Common pitfalls

Pitfall Fix
“Option 'customConditions' can only be used when moduleResolution is node16/nodenext/bundler” Switch the resolver or remove the flag.
Your condition is ignored at runtime Pass --conditions=<name> to Node (or set NODE_OPTIONS="--conditions=…"), and configure bundler conditions.
Third-party tooling (eslint, SonarQube ≤ v9.9) warns “unknown compiler option” Upgrade those tools; they need TS ≥ 5 support.
Using "browser" and "node" without "default" Some environments mis-detect themselves; always add a safe "default" branch.(nodejs.org)

7. Best practices checklist

  • Use short, environment-style names: "browser", "deno", "edge-runtime", "production".
  • Document every custom condition in your README for consumers.
  • Keep conditions mutually exclusive when they represent modes (e.g. “development” vs “production”).
  • Prefer moduleResolution:"bundler" for front-end projects—it side-steps many Node-specific edge cases.
  • Test the runtime path with node --conditions=<name> -p "import('my-lib').then(m=>console.log(m))".

Quick reference snippet

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "customConditions": ["browser", "development"],
    "esModuleInterop": true,
    "strict": true
  }
}

Add the same condition list to your bundler and (optionally) to Node’s CLI flags, and you’re set—TypeScript, your dev server, and production runtime will all agree on which files to load.


That’s it! With customConditions, TypeScript finally speaks the same conditional-exports language as Node and modern bundlers, eliminating whole classes of “works in the editor, breaks in prod” bugs.

Canonical conditions

Below is a “cheat-sheet” of the condition names you’re most likely to see (or want to publish) in a `package.json { "exports": … }` or `imports` map, grouped by where the names come from and how broadly they’re supported.

1. Built-into Node itself

These are always evaluated by Node (you don’t need to pass --conditions to enable them) and therefore by TypeScript when you use moduleResolution:"node16", "nodenext" or "bundler".

Condition Typical purpose Notes
import ES-module consumers (import …) Highest priority when the current module graph is ESM.
require CommonJS consumers (require() calls) Picked when the calling module is CJS.
node The Node runtime as a whole Good place for Node-specific polyfills.
default Universal fallback Always include this so unknown tools still work.
module-sync Synchronous module system that honours import (used by ts-node etc.) Added in Node 20.
node-addons Branch that loads native .node addons Skipped if Node is run with --no-addons.

(raw.githubusercontent.com)


2. Community-standard conditions (explicitly documented by Node)

These aren’t hard-coded into Node, but the docs give them an “official” blessing, so tooling recognises them if you list them and, at runtime, you opt-in with node --conditions=<name> (or your bundler’s equivalent).

Condition When to use it Guidance
types Point to .d.ts files (or a .ts build) Should be the first key in the object so TS picks it without touching JS.
browser Any web-browser environment Include a matching "default" or "node" branch to keep Node users happy.
development Dev-only builds with extra errors, React debug helpers, etc. Mutually exclusive with production.
production Minified / dead-code-stripped output Mutually exclusive with development.

(raw.githubusercontent.com)


3. Runtime-specific keys (WinterCG “Runtime Keys” registry)

Modern tooling (Vite, Bun, Deno, Cloudflare Workers, etc.) relies on these strings so one package can publish pre-built bundles for every platform. The list lives in the Runtime Keys spec and is growing, but the most established ones today are:

Runtime Canonical condition
Node (Edge/light servers, Lambda, etc.) node (already built-in)
Bun bun
Deno deno
Electron (desktop apps) electron
React Native (mobile) react-native
Edge-Light (Vercel’s edge runtime) edge-light
workerd / Cloudflare Workers workerd
Fastly Compute@Edge fastly

(See the full registry for many more such as edge-routine, netlify, lagon, moddable, wasmer, etc.) (runtime-keys.proposal.wintercg.org)


4. De-facto bundler / tooling conventions

Not (yet) standardised, but you’ll often bump into them:

Condition Where it appears Typical use-case
worker or serviceworker Rollup-plugins, Workbox builds Bundle a web-worker-safe version.
modern / es2019 / es2022 Some library builds (e.g. Preact) Ship later-syntax bundles for evergreen browsers.
debug Libraries like debug or pino Drop debugging helpers when not wanted.
edge-runtime Older Vercel examples (superseded by edge-light) Same goal as edge-light.

How to decide which ones you should expose

  1. Start with the core set: import, require, node, default.
  2. Add types if you ship separate .d.ts or .ts files.
  3. Pick one platform key per actual runtime you ship (e.g. browser, deno, bun).
  4. Optionally split dev/prod if your dev build is materially different.

Always remember:

  • Put the most specific condition first in each object; resolution stops at the first match.
  • Keep mutually exclusive branches (development vs production) parallel, not nested, to avoid subtle dead-code-elimination bugs.
  • Fallback to "default" unless you have a very good reason not to—that way unknown tools, or a future runtime, still see something that works.

With those conventions you’ll hit the sweet spot where editors, TypeScript, Node, Bun, Deno, and SaaS edge platforms all pick the right file without you juggling extra build steps.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment