TypeScript From Zero
Modules, Declaration Files and tsconfig in Real Projects
ES modules versus CommonJS, moduleResolution, paths, .d.ts files, @types, project references, and tsconfig for Node, Vite and Next.js.
On this page
- Words used in this lesson
- ES modules and CommonJS in TypeScript
- moduleResolution: what the options mean
- import type and verbatimModuleSyntax
- paths: shortcuts for imports
- Declaration files (.d.ts) and @types
- Project references and monorepos (an outline)
- One tsconfig is not enough for every project
- Emitting declarations for a library
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: a TypeScript project is many files and many packages, and tsconfig.json tells tsc how to find them. Two settings matter most: module (what kind of imports you write and emit) and moduleResolution (how an import name is turned into a file). Match them to who runs your code: Node, a bundler, or other people's projects.
The first lessons used one tiny config. Real projects have libraries without types, files that only hold types, shortcuts like @/lib/x, a frontend and a backend side by side, and sometimes a package you publish. This lesson explains each piece and shows what tsc really does, from small projects I compiled for it. For the basic config line by line, read Lesson 0. For Node itself see Full-Stack Q&A, Lesson 6, and for a React frontend see Full-Stack Q&A, Lesson 4.
What I ran / checked. I compiled about ten small projects with TypeScript 7.0.2 on Node v22.22.0, with @types/node 22, lodash 4.18.1 with @types/lodash 4.17.26, @types/express 5.0.6, and ran @arethetypeswrong/cli 0.18.5 through npx. Error text is copied from tsc --pretty false. Where a program prints something, I ran it with node. I read, but did not run: the tsconfig.json of the Next.js 16.3.5 app in this blog's own repository, and the create-vite React-TypeScript templates from the vitejs/vite repository on GitHub (the main branch when I read them). Settings that appear in those files are quoted from them, not tested by me. Other TypeScript versions can behave differently; this lesson was written about 7.0.2 and the "removed" options below are about that version.
Words used in this lesson
- Module is a file with
importorexport. It has its own scope. - ES module (ESM) is the standard JavaScript module system:
import x from "y",export const z. CommonJS (CJS) is the older Node system:require("y")andmodule.exports. - Module resolution is the way
tsc(or Node, or a bundler) turns the text infrom "..."into a real file. - Bundler is a tool such as Vite, webpack or Next.js that joins your files into a few output files. It has its own, looser resolution rules.
- Declaration file (
.d.ts) has only types, no code. It describes JavaScript that exists somewhere else. @typespackage is a package of declaration files for a library that has none of its own. They come from the DefinitelyTyped project and are named@types/<library>.- Ambient means "declared here, but it exists at runtime in some other way".
declaremarks ambient things. - Augmentation means adding to a type that already exists, in another file or package.
- Path mapping (
paths) is atsconfigsetting that makes an import name like@/lib/xpoint to a folder. - Project reference lets one
tsconfigdepend on another, so that big projects build in parts. - Monorepo is one repository with many packages.
ES modules and CommonJS in TypeScript
Node has two module systems, and a file is one or the other. With "module": "nodenext", TypeScript follows Node's rule:
| File | Is a... |
|---|---|
.mts / .mjs | ES module, always |
.cts / .cjs | CommonJS module, always |
.ts / .js | Decided by "type" in the nearest package.json: "module" gives ESM, anything else (or nothing) gives CJS |
The type of a file also decides what tsc writes. I made a project with "type": "module" and these files: util.ts, legacy.cts and main.ts.
// src/main.tsimport { double } from "./util";import { old } from "./legacy.cjs";console.log(double(2), old);src/main.ts(1,24): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './util.js'?In an ES module, Node requires the file extension, so TypeScript requires it too. And you write .js, the extension of the output file, even though the source is util.ts. (Lesson 0 showed the other way: with allowImportingTsExtensions and noEmit you may write .ts.) After I wrote ./util.js, it compiled, and the output was:
dist/main.js import { double } from "./util.js"; import { old } from "./legacy.cjs"; console.log(double(2), old);dist/legacy.cjs "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.old = void 0; exports.old = "cjs";node dist/main.js printed 4 cjs. One project, two kinds of output: the .cts file became CommonJS, and the rest stayed ESM.
The same code in a project without "type": "module" becomes CommonJS. I used import { double } from "./util" (no extension needed there) and the output was const util_1 = require("./util"); and (0, util_1.double)(4). TypeScript also has import fs = require("node:fs") for CommonJS; it compiled to const fs = require("node:fs"). You will see it in old code. New code should use import.
Mixing the two
Can CJS code use an ES module? I put an esm.mts file next to a CommonJS main.ts and used a normal import { z } from "./esm.mjs". tsc accepted it, and node dist/main.js printed 1 hi 1 on Node v22.22.0, because modern Node can require() an ES module. Top-level await is the one thing that stays ESM-only:
src/main.ts(3,14): error TS1309: The current file is a CommonJS module and cannot use 'await' at the top level.The reverse also has rules, and import.meta is ESM-only too: in a CJS file, import.meta.url gave error TS1470: The 'import.meta' meta-property is not allowed in files which will build into CommonJS output.
A gotcha that tsc does not catch: __dirname and __filename do not exist in an ES module. With @types/node, tsc accepted console.log(__dirname) with no error in a "type": "module" project. Node then failed:
ReferenceError: __dirname is not defined in ES module scopeUse import.meta.dirname (it printed fine on Node 22.22 in my test). I would pick one system for new code: ES modules, with "type": "module".
moduleResolution: what the options mean
The module setting is about syntax. moduleResolution is about finding files. I built a small project that imports a package called fancy, which has an exports map with two entries (. and ./extra), and a relative import without an extension. Then I tried the main combinations:
module | moduleResolution | Result |
|---|---|---|
nodenext | nodenext (the default for it) | TS2835 on the extensionless relative import. The package imports were fine. |
node16 | node16 | Same as nodenext here. |
esnext | bundler | No errors. |
preserve | bundler | No errors. |
commonjs | bundler | No errors. |
commonjs | (not set) | No errors. |
| any | node10 / node | error TS5108: Option 'moduleResolution=node10' has been removed. |
| any | classic | error TS5108: Option 'moduleResolution=Classic' has been removed. |
In 7.0.2 the two oldest modes are gone. Old tutorials that say "moduleResolution": "node" will not work; use the choices below.
nodenext (and its older sibling node16) copies what Node really does. Extensions are required in ESM, package.json exports maps are used, the file type decides the module kind. Pick it when Node runs your output: servers, CLIs, scripts, and libraries for Node.
bundler copies what Vite, webpack, esbuild and Next.js do. Imports without extensions are fine, exports maps are used, and you may import files like .tsx. It describes a tool that will process your code, so it is only right when that tool runs. Pick it with module: "esnext" or "preserve" for frontend apps and for code that a bundler will join. Do not use it for something that Node runs directly: it lets you write imports that Node will reject.
module: "preserve". It keeps your import and export lines as you wrote them (the output is not rewritten to another module system), and it is the natural partner of bundler. The create-vite template I read uses module: "esnext" with moduleResolution: "bundler" for the app code.
The type checker does not run your code. tsc can say "fine" about an import that Node will refuse. The rule: the settings must match the thing that runs the program. If Node runs it, use nodenext. If a bundler runs it, use bundler.
import type and verbatimModuleSyntax
Lesson 0 showed the errors. Here is what the setting changes in the output. side.ts prints a line when loaded and exports val (a value) and T (a type). main.ts imports val and does not use it:
import { val, type T } from "./side.js";import type { T as T2 } from "./side.js";const t: T = "x";const t2: T2 = "y";With verbatimModuleSyntax: true the output of main.js was:
import { val } from "./side.js";const t = "x";const t2 = "y";and running it printed side.ts ran and then x y. With verbatimModuleSyntax: false the whole import vanished, because val was never used, and the same program printed only x y: side.ts never ran. That is the point of the setting. What you write is what you get. With it on, import { type T } and import type are erased and a normal import is kept, even if unused. Without it, TypeScript has to guess whether an import is "only a type" and it can drop code that has side effects.
verbatimModuleSyntax also refuses ES syntax in a file that will be CommonJS:
src/a.ts(1,10): error TS1295: ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'. Adjust the 'type' field in the nearest 'package.json' to make this file an ECMAScript module, or adjust your 'verbatimModuleSyntax', 'module', and 'moduleResolution' settings in TypeScript.So the setting pushes you toward real ES modules. In a CJS project you would write import x = require("x") or turn it off. For new code I keep it on.
paths: shortcuts for imports
paths maps an import name to a folder, so you write @/lib/greet.ts and not ../../../lib/greet.ts:
{ "compilerOptions": { "module": "nodenext", "noEmit": true, "allowImportingTsExtensions": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src"] }tsc was happy with import { hello } from "@/lib/greet.ts", and a wrong file name gave error TS2307: Cannot find module '@/lib/nope.ts' or its corresponding type declarations. But running the file with Node:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@/lib' imported from .../p1/src/main.tsnpx tsx src/main.ts printed hi, because tsx reads paths. So here is the rule: paths changes how tsc understands the import. It does not change the output. TypeScript will not rewrite @/lib/x in your .js files. Something else must do it: a bundler (Vite, Next.js do this for you, with resolve.alias or from the tsconfig), a runner like tsx, or Node's own imports field in package.json ("#lib/*"). Without one of those, the program breaks at runtime. Lesson 0 said the same about Node's type stripping: it ignores tsconfig.json. The Next.js tsconfig of this blog has "paths": { "@/*": ["./*"] } and Next handles the rewrite.
Declaration files (.d.ts) and @types
A .d.ts file contains only types. There are three common reasons you meet one:
- A library ships its own types (the
typesfield or anexportsentry inpackage.json). - A library does not, and the community wrote
@types/<name>. - You write one yourself, for code that has no types.
A library with no types. I imported lodash (plain JavaScript) with no @types:
src/main.ts(1,15): error TS7016: Could not find a declaration file for module 'lodash'. '.../node_modules/lodash/lodash.js' implicitly has an 'any' type. Try `npm i --save-dev @types/lodash` if it exists or add a new declaration (.d.ts) file containing `declare module 'lodash';`node ran the same file and printed [ [ 1, 2 ], [ 3 ] ]. This is a type error and not a runtime error. After npm i -D @types/lodash the error went away, and now a wrong use is caught: const n: string = _.chunk([1, 2, 3], 2) gave error TS2322: Type 'number[][]' is not assignable to type 'string'.
@types/lodash came from DefinitelyTyped, a big repository on GitHub where people keep types for libraries. If a library has @types/x, install it as a dev dependency, as the types are not needed at runtime. Many modern libraries ship their own types and need nothing. (React is a case where both exist: you install react and @types/react, Lesson 6.) Match the major version: @types/node 22 goes with Node 22. With "types": ["node"] in the config, as in Lesson 0, only the listed @types packages are loaded as globals; imported packages still find their own types.
Declaring a module yourself
If no types exist, you can write them. A file called src/types.d.ts:
declare module "untyped-lib" { export function shout(s: string): string;}declare module "*.css" { const classes: Record<string, string>; export default classes;}Then import { shout } from "untyped-lib" is typed, and const a: number = shout("x") fails with TS2322: Type 'string' is not assignable to type 'number'. The "*.css" line is a wildcard: it types every CSS import as an object of strings (the CSS-modules shape). Both are promises to the checker. The real package must exist at runtime, and nothing checks that your description is true. A shortcut, declare module "untyped-lib2"; with no body, makes everything from that module any (I checked: a("x").whatever compiled). Without the wildcard, a bare side-effect import such as import "./app.css" gave:
src/main.ts(2,8): error TS2882: Cannot find module or type declarations for side-effect import of './app.css'.A .d.ts file is not compiled to anything. It must be inside include. And note that a .d.ts with a top-level import or export is a module, and then declare module "x" in it augments x instead of declaring it from nothing. I tested it: with import type { Foo } from "./foo.js" added at the top of that file, import { shout } from "untyped-lib" failed again with TS2307: Cannot find module 'untyped-lib' or its corresponding type declarations. (and skipLibCheck hid any error in the .d.ts itself). This is a common surprise when writing declarations.
Global and module augmentation
To add to something that exists, you use augmentation. Two real cases from my tests.
Typing process.env. Without help, process.env.DATABASE_URL is string | undefined, and const n: string = url fails with TS2322 (and Type 'undefined' is not assignable to type 'string'). Also globalThis.appVersion = "1" gave error TS7017: Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature. I added src/globals.d.ts:
declare global { var appVersion: string; namespace NodeJS { interface ProcessEnv { DATABASE_URL: string; PORT?: string; } }}export {};and both lines compiled. export {} makes the file a module, which is what allows declare global. Be careful with this one: the type now says DATABASE_URL is a string, but nothing checks that it is set when the program starts. Lesson 6 shows how to validate environment variables at runtime.
Adding a field to a library type. Express middleware often sets req.userId. I added, in a normal .ts file:
import type { Request } from "express";declare module "express-serve-static-core" { interface Request { userId?: string; }}export function who(req: Request): string { return req.userId ?? "anon";}req.userId compiled. I had to augment express-serve-static-core, the package where @types/express defines Request, and not express itself. If the module name is wrong, the augmentation does not apply. Interfaces merge, so adding members works; you cannot change an existing member's type this way.
Project references and monorepos (an outline)
When one repository has several packages (core, app), type checking everything as one big project gets slow, and the packages can import each other's private files. Project references split it. I made core and app:
// tsconfig.base.json (shared){ "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "composite": true, "declaration": true, "types": ["node"] } }// app/tsconfig.json{ "extends": "../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"], "references": [{ "path": "../core" }] }// tsconfig.json at the root, only a list{ "files": [], "references": [{ "path": "core" }, { "path": "app" }] }core/package.json has "name": "@mono/core" and types/exports pointing at dist/index.d.ts. I linked it into node_modules/@mono/core by hand (a real repo gets this from npm, pnpm or yarn "workspaces"). Then:
$ npx tsc -p appapp/src/main.ts(1,33): error TS2307: Cannot find module '@mono/core' or its corresponding type declarations.$ npx tsc -b # exit 0core/dist/index.js core/dist/index.d.ts app/dist/main.js app/dist/main.d.ts$ node app/dist/main.js{ x: 3, y: 0 }composite: truemakes a project usable by others: it needsdeclarationand a known file list.tsc -p appalone fails, becausecorehas not been built, so there are no.d.tsfiles to read.tsc -b("build mode") builds the referenced projects first, in the right order, and skips projects that did not change.appreads the declaration files ofcore, not its source. I changedPoint.ytostringincore, rantsc -b, andappreportederror TS2322: Type 'number' is not assignable to type 'string'.at its own line.
I am describing only the outline: I did not test watch mode, incremental caching, or tools such as pnpm, Turborepo or Nx. The ideas to keep: one shared base config with extends, one tsconfig per package, references for dependencies, and tsc -b in CI.
One tsconfig is not enough for every project
The right config depends on who runs the code.
A Node app or CLI (the setup used in this series):
{ "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "noUncheckedIndexedAccess": true, "skipLibCheck": true, "verbatimModuleSyntax": true, "isolatedModules": true, "outDir": "dist", "rootDir": "src", "types": ["node"] }, "include": ["src"]}nodenext because Node runs the output, outDir because you ship dist, and .js extensions in your imports. If you run the TypeScript directly (node file.ts or tsx), use noEmit, allowImportingTsExtensions and erasableSyntaxOnly, as in Lesson 0.
A Vite app. The create-vite template (template-react-ts) has a root tsconfig.json that is only a list of references, plus one config for the browser code and one for vite.config.ts. From the app config I read: module: "esnext", moduleResolution: "bundler", jsx: "react-jsx", noEmit: true, verbatimModuleSyntax: true, allowImportingTsExtensions: true, erasableSyntaxOnly: true, noUnusedLocals, noUnusedParameters, types: ["vite/client"], lib: ["ES2023", "DOM"], and include: ["src"]. The second file, for the config script that Node runs, uses module: "nodenext" and types: ["node"]. That is the same idea as above: different code that runs in different places gets different configs.
A Next.js app. This blog is one (Next.js 16.3.5), and its tsconfig.json has target: "ES2017", lib: ["dom", "dom.iterable", "esnext"], strict: true, noEmit: true, module: "esnext", moduleResolution: "bundler", jsx: "react-jsx", isolatedModules: true, incremental: true, allowJs: true, resolveJsonModule: true, esModuleInterop: true, skipLibCheck: true, a plugins: [{ "name": "next" }] entry for the editor, paths: { "@/*": ["./*"] }, and an include with next-env.d.ts and the .next type folders. Next writes next-env.d.ts for you; do not delete it. Next compiles the code itself, so tsc is only the checker (noEmit).
| Node app | Vite / Next.js app | |
|---|---|---|
module | nodenext | esnext or preserve |
moduleResolution | nodenext | bundler |
noEmit | no, if you ship dist | yes: the bundler emits |
lib | the default for your target | add "dom" for browser code |
jsx | not needed | react-jsx |
types | ["node"] | ["vite/client"] or Next's own |
| Extensions in imports | .js | none needed |
Emitting declarations for a library
If you publish a package, the people who use it need .d.ts files. I made a tiny library, tiny-lib:
{ "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "rootDir": "src", "outDir": "dist", "declaration": true, "declarationMap": true, "sourceMap": true, "isolatedDeclarations": true, "verbatimModuleSyntax": true, "types": [] }, "include": ["src"] }export interface Options { loud?: boolean }export function greet(name: string, opts: Options = {}): string { /* ... */ }export const makeId = () => Math.random().toString(36).slice(2);isolatedDeclarations is a strict mode that forces every exported thing to have an explicit type, so that tools can write the .d.ts file by looking at one file at a time. It reported my exported arrow function:
src/index.ts(6,29): error TS9013: Expression type can't be inferred with --isolatedDeclarations.I wrote (): string => and it compiled. dist/ then contained index.js, index.js.map, index.d.ts and index.d.ts.map, and index.d.ts was:
export interface Options { loud?: boolean;}export declare function greet(name: string, opts?: Options): string;export declare const makeId: () => string;The function bodies are gone and opts = {} became opts?: Options. declarationMap makes "go to definition" jump to your .ts source.
Then package.json tells tools where to look:
{ "name": "tiny-lib", "type": "module", "files": ["dist"], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } } }I put "types" first inside each exports entry; that is the usual advice (I did not test other orders). To check the result, I packed the library and ran npx @arethetypeswrong/cli --pack . (a tool that tries your package the way different tools would). Without the top-level main and types it reported node10 as Resolution failed. With them, the output was (I replaced the tool's green and warning icons with words):
node10: ok node16 (from CJS): warning, ESM (dynamic import only)node16 (from ESM): ok (ESM) bundler: okThe warning is true: this library is ES-module-only, so a CommonJS user needs a dynamic import(). Shipping both formats is possible, but it doubles the work; many new libraries are ESM-only. Set skipLibCheck to false in the library's own config at least once, so that you check your own .d.ts files (Lesson 0, Q8).
Try it
- Make a project with
"type": "module"andmodule: nodenext. Import./utilwithout an extension. What doestscsay? Fix it and build todist. What do you write in the import, and why? - Rename a file to
.ctsand import it from an ES module withimport { x } from "./legacy.cjs". Build and look atdist. - Turn
verbatimModuleSyntaxon and off for a file that imports a value it does not use. Which one keeps the import? Which one keeps the side effect? - Add
pathsand import with@/lib/x.ts. Run withtsc,nodeandtsx. - Import a plain-JS package (
lodash) with and without@types. What are the two states of_? - Add
declare global { namespace NodeJS { interface ProcessEnv { PORT: string } } }in a.d.tswithexport {}. What wasprocess.env.PORTbefore and after?
Answers.
TS2835 ... Did you mean './util.js'?. In an ES module you write the output extension,./util.js. Node requires an extension and TypeScript copies that rule.- The
.ctsfile becamedist/legacy.cjs, a CommonJS file withexports.old = ...;main.jsstayed ESM. truekeeps it (import { val } from "./side.js"), and the side effect ran.falseremoved the import, and the side effect did not run.tscandtsxaccept it.nodefails withERR_MODULE_NOT_FOUND: Cannot find package '@/lib'.- Without types:
TS7016and_isany. With@types/lodash: fully typed, and a wrong use fails. - Before:
string | undefined. After:string(and the type now trusts you).
Interview questions
What is the difference between module and moduleResolution? (Must know)
module says what kind of module code TypeScript expects and writes: ESM or CommonJS, and how import is emitted. moduleResolution says how the text in an import turns into a file: which extensions, whether package.json exports are read, and so on. They go in pairs: nodenext with nodenext for Node, esnext or preserve with bundler for a bundler. In 7.0.2 I tried node10 and classic, and both gave TS5108 ... has been removed.
When do you use nodenext and when bundler? (Must know)
I use nodenext when Node runs the output, because it follows Node's real rules, including required file extensions in ES modules. I use bundler when a tool such as Vite or Next.js processes the code, since those tools accept extensionless imports. I do not use bundler for code that Node runs directly: tsc would accept imports that Node rejects.
Why do I have to write ./util.js in a .ts file? (Must know)
With nodenext and ES modules, imports need a file extension, and the extension must be the one of the output file, because TypeScript does not rewrite import paths. In my test the plain ./util gave TS2835 ... Did you mean './util.js'?. If I do not emit (for example I run node file.ts or tsx), I can turn on allowImportingTsExtensions and write .ts.
What does verbatimModuleSyntax do? (Must know)
It makes the output match what I wrote. import type and import { type X } are removed, and every normal import stays, even if I do not use it. In my test, with it on, the unused val import stayed and the side effect in the other file ran. With it off, TypeScript removed the import and the side effect disappeared. It also refuses ES import and export in a file that becomes CommonJS (TS1295).
Do paths work at runtime? (Senior signal)
No. paths only changes how the type checker finds files. It does not rewrite import paths in the output. In my test tsc and tsx accepted @/lib/greet.ts, but node failed with ERR_MODULE_NOT_FOUND: Cannot find package '@/lib'. The runtime needs its own help: a bundler alias, tsx, or the imports field in package.json.
What is a .d.ts file, and what is @types? (Must know)
A .d.ts file holds types for code that exists elsewhere, and produces no JavaScript. @types/<name> packages are .d.ts files for libraries that have none of their own; they are maintained in the DefinitelyTyped repository. When I imported lodash without them I got TS7016, and the message told me to install @types/lodash or add a declare module. I install them as dev dependencies and match the major version of what they describe.
How do you add a property to req in Express, or type process.env? (Senior signal)
With augmentation. For process.env I add a .d.ts with declare global { namespace NodeJS { interface ProcessEnv { DATABASE_URL: string } } } and export {}. For req.userId I use declare module "express-serve-static-core" { interface Request { userId?: string } }. Interfaces merge, so adding works. The honest caveat: this is a promise, and the environment variable may still be missing at runtime, so I validate it at startup (Lesson 6).
A .d.ts file with declare module "x" does not seem to work. Why? (Senior signal)
The usual cause is a top-level import or export in that file. That makes the file a module, and declare module "x" inside it then augments an existing x instead of creating a new one, so a package with no types is still untyped. For a fresh declaration I keep the file free of imports and exports. For a global augmentation I do the opposite: add export {} and wrap things in declare global.
What are project references, and why use them? (Senior signal)
They let one tsconfig depend on another. Each package builds on its own into .d.ts and .js files, and tsc -b builds them in the right order and skips the ones that did not change. It makes big monorepos faster and stops one package from importing another's private source. In my test tsc -p app alone failed with TS2307 until core was built, and a type change in core showed up as an error in app after tsc -b.
What do you set to publish a library with types? (Senior signal)
In tsconfig: declaration: true, usually declarationMap, a clear outDir and rootDir. In package.json: exports with types first, plus main and types for older tools, and files: ["dist"]. I test the package with npx @arethetypeswrong/cli --pack .: in my run, without main and types, node10 resolution failed. With isolatedDeclarations I am forced to write explicit types on exports, which makes the .d.ts stable. If I ship only ESM, CommonJS users need dynamic import(), and the tool warns about it.
How does a Next.js or Vite tsconfig differ from a Node one? (Must know)
A bundler compiles the code, so tsc only checks: noEmit: true, module: "esnext" (or preserve), moduleResolution: "bundler", jsx: "react-jsx", and "dom" in lib. The Vite template also splits the browser code and the vite.config.ts into two configs, because they run in different places. A Node app uses nodenext, emits dist, and needs .js extensions in imports.
Why can dirname pass tsc and still crash? (Trap)
Because @types/node declares __dirname as a global, and tsc does not know that the file is an ES module where Node does not define it. In my test tsc printed nothing, and node said ReferenceError: __dirname is not defined in ES module scope. In an ES module I use import.meta.dirname (I ran it on Node 22.22). It is a good example of a type that is correct for one module kind and wrong for another.
Key takeaways
- A file is ESM or CJS. With
nodenext,.mts/.ctsand the"type"field decide, and ESM imports need the output extension (./util.js). - Pick
moduleResolutionby who runs the code:nodenextfor Node,bundlerfor Vite and Next.js.node10andclassicare removed in TypeScript 7.0.2. verbatimModuleSyntaxkeeps what you write. Useimport typefor types. It keeps side-effect imports, and refuses ES syntax in CommonJS files.pathsis for the checker only. Something else must rewrite the import at runtime.@types/xsupplies types for a JS library;declare moduleanddeclare globalfill the gaps; a top-levelimport/exportchanges what a.d.tsfile means.- Use project references and
tsc -bfor monorepos, with one base config and one config per package. - For a library:
declaration, anexportsmap withtypesfirst,isolatedDeclarationsif you can, and check the package with@arethetypeswrong/cli.
What's next
Lesson 6: TypeScript with React, Node and Runtime Validation. With the project set up, we type components, hooks and events, write an Express handler, and meet the idea that closes the gap this lesson left open: types vanish at runtime, so data from outside must be checked with a schema library (Zod).
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Validation at the Edge: Schemas, DTOs and Trusting Nothing · Backend in Depth
- Advanced TypeScript for Interviews: Generics, Narrowing, Utility and Conditional Types · Senior Frontend Depth
- TypeScript Interview Questions · Full-Stack Questions
Part of the study guide
Related series