The tsconfig module settings I finally understand

module, moduleResolution and the new verbatimModuleSyntax were three knobs I turned until the errors went away. TypeScript 5.0 finally made me read what they do, and now I know why my Vite app and my Node service need different ones.

For years my process for tsconfig.json was to copy one from the last project, and when an import turned red, change moduleResolution until it didn't. It worked, mostly, and I never knew why.

TypeScript 5.0 came out in March with two new values in exactly that corner, moduleResolution: "bundler" and verbatimModuleSyntax. I was upgrading the app I moved from Create React App to Vite a year and a half ago, and a Node service in the same week, so I finally sat down and read the docs. Here is what I should have known in 2020.

Three settings, three questions

  • module answers: what module syntax does the emitted JavaScript use? commonjs gives you require, esnext keeps import. With node16 or nodenext it also means "follow Node's rules", which depend on the type field in package.json.
  • moduleResolution answers: given import { x } from './thing' or from 'some-package', which file is that? This is the one I kept turning.
  • verbatimModuleSyntax answers: which imports survive into the output? The new one, and the one that changed my habits.

The first two are easy to confuse because module can set moduleResolution for you, and the old default for the second one is the problem.

The old resolution is older than you think

moduleResolution: "node" describes how Node resolved modules around version 10. It looks for index.js, it walks up node_modules, and it has no idea what the exports field in a package.json is. Packages have used exports for years to point import and require at different files, and TypeScript on the old setting can't see them. That's the "has no exported member" error on a package that clearly has it, and it's why 5.0 added the alias node10, so the age is in the name.

The two replacements:

  • node16 (or nodenext): Node's actual rules. exports works, and relative imports in an ES module must have an extension, ./thing.js, even though the file on disk is thing.ts. It feels wrong the first week and then it's fine.
  • bundler: for code that a bundler will put together. exports works, extensions stay optional, and TypeScript stops pretending it needs to know how Node would find the file, because Node won't be the one looking.

The imports that vanished

Before 5.0, TypeScript decided on its own which imports to drop from the output. import { User } from './user' where User is only a type? Gone, because it's unused at runtime. Fine for tsc. Not fine for Vite, esbuild and Babel, which transpile one file at a time and can't tell whether User is a type or a value exported from another file, so they keep the import and the browser asks for an export that doesn't exist.

isolatedModules was the warning light for that. verbatimModuleSyntax is the fix: imports are emitted exactly as you wrote them, and anything that's only a type has to say so.

ts
// src/api/user.ts
import type { User } from './user'; // dropped entirely from the output
import { formatName, type Options } from './format'; // becomes import { formatName } from './format'
import { type Theme } from './theme'; // becomes import {} from './theme', the module still loads

With it on, forgetting the type is an error with a clear message: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled. My editor adds the word for me now. It also switches on the isolatedModules checks, so that line can go.

The catch: it won't let you write import in a file that's going to be emitted as CommonJS, because that's not verbatim anymore. On a service that still emits commonjs it's a wall of errors, so it stays off there until that service moves to ES modules.

What I use now

The Vite app, with React:

json
// tsconfig.json for a Vite app
{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "jsx": "react-jsx",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

noEmit because Vite's esbuild does the emitting and tsc only checks. This is close to what create-vite scaffolds today, minus the lines I didn't understand and plus verbatimModuleSyntax.

The Node service, on Node 18 and ES modules:

json
// tsconfig.json for a Node service
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "verbatimModuleSyntax": true,
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["src"]
}
json
// package.json
{
  "type": "module"
}

No moduleResolution line, because module: "Node16" sets it to node16 and setting it to anything else is an error anyway. Relative imports get .js extensions. That was the entire change for the service, and the "has no exported member" errors went away with it.

A few more things

  1. moduleResolution: "bundler" needs module set to ES2015 or newer. With commonjs it refuses to start.
  2. A package that only ships CommonJS, with types written for it, may still need esModuleInterop. I left it on, it costs nothing.
  3. @tsconfig/bases has a base for Node 18, for Vite with React and a few others, maintained by the community. Extending one is a fine start if you'd rather not read all of this.

I wish I'd read the docs three years ago, it would have saved a few evenings of knob turning.

Read more here: