Configuring tsconfig for a New TypeScript Project
1. Overview
The tsconfig.json file determines how TypeScript type-checks and compiles your code. If misconfigured, you might encounter import errors at runtime, or the compiler could miss many type errors.
This article builds a tsconfig.json for a new Node.js project from scratch. We’ll enable strict type checking, choose the right module system, and separate build configuration from test configuration. By the end, you’ll have a working configuration and understand every line in it.
2. Initializing the Project
Create a directory and install TypeScript as a dev dependency:
mkdir order-service && cd order-service
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init
The tsc --init command generates a tsconfig.json with many comment lines. While useful for reference, this file is hard to maintain. We’ll replace it with a concise version that clearly documents each option.
Next, declare that the project uses ES Modules in package.json:
{
"name": "order-service",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js"
}
}
The "type": "module" line tells Node.js that .js files are ES modules. This choice directly affects how TypeScript resolves modules, as you’ll see in section 3.
3. Minimal Configuration for Node.js
Here’s the base tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"rootDir": ".",
"outDir": "dist",
"sourceMap": true,
"declaration": false,
"strict": true,
"skipLibCheck": true
},
"include": ["src", "tests"]
}
3.1. target, lib, and types
targetis the JavaScript version the compiler outputs.ES2022works well with Node.js 18+. With this target,async/await, optional chaining, and class fields are preserved and not downleveled.libdeclares which APIs are available at runtime. Without"DOM", you won’t accidentally calldocumentorwindowin your server code.types: ["node"]loads only Node.js types. Without this, TypeScript auto-loads all packages undernode_modules/@types, which can inadvertently bring in unwanted global types.
3.2. module and moduleResolution
For projects running directly on Node.js, use NodeNext for both options. In this mode, TypeScript mimics how Node.js resolves imports. It reads the "type" and "exports" fields in package.json, and requires relative imports to include the file extension.
Suppose you have two files:
// src/order.ts
export interface Order {
id: string;
items: string[];
note?: string;
}
export function totalItems(order: Order): number {
return order.items.length;
}
// src/index.ts
import { totalItems } from "./order";
console.log(totalItems({ id: "A1", items: ["pho", "tra-da"] }));
Running npx tsc --noEmit will error:
src/index.ts:1:28 - error TS2835: Relative import paths need explicit file extensions
in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
Did you mean './order.js'?
You must fix it to import { totalItems } from "./order.js";. The extension is .js, not .ts, because Node.js reads the compiled file at runtime. This rule seems unusual at first, but it prevents the ERR_MODULE_NOT_FOUND error when you deploy.
3.3. rootDir and outDir
outDir is where the compiler places .js files after building. rootDir determines the folder structure inside outDir. In the base file, rootDir is "." because include contains both src and tests. In section 6, the build config will change rootDir to src, so dist/index.js sits at the right place instead of dist/src/index.js.
4. Strict Mode and Additional Compiler Flags
The strict: true flag enables a group of checks, including strictNullChecks, noImplicitAny, and strictFunctionTypes. For new projects, you should always enable this flag. Enabling it later on a large codebase is much more work.
Beyond strict, add these flags:
{
"compilerOptions": {
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
Merge these lines into the compilerOptions block of the base file. The first two flags deserve deeper explanation.
4.1. noUncheckedIndexedAccess
By default, TypeScript assumes an index-accessed element always exists. In reality, it doesn’t:
import type { Order } from "./order.js";
const ordersById: Record<string, Order> = {};
const order = ordersById["A1"];
console.log(order.id);
With this flag enabled, the type of order becomes Order | undefined, and the compiler reports:
error TS18048: 'order' is possibly 'undefined'.
You’re forced to check before using: if (order) { console.log(order.id); }. Without the flag, the code still compiles but throws a TypeError at runtime.
4.2. exactOptionalPropertyTypes
This flag distinguishes between “property not present” and “property with value undefined”:
import type { Order } from "./order.js";
const order: Order = { id: "A2", items: [], note: undefined };
error TS2375: Type '{ id: string; items: never[]; note: undefined; }' is not assignable
to type 'Order' with 'exactOptionalPropertyTypes: true'.
This distinction matters when using Object.keys, the in operator, or JSON serialization. If you truly want to allow undefined, declare it explicitly: note?: string | undefined.
Note:
exactOptionalPropertyTypessometimes conflicts with types from third-party libraries, especially form and ORM libraries. If this flag causes unmanageable errors outside your control, you can turn it off. ButnoUncheckedIndexedAccessis worth keeping.
5. Module Interoperability
Add these two flags to compilerOptions:
{
"compilerOptions": {
"verbatimModuleSyntax": true,
"isolatedModules": true
}
}
verbatimModuleSyntax has one simple rule: imports without the type keyword stay in the output, while imports with type are removed. This makes the compiled result predictable. In return, you must explicitly mark imports used only for types:
// Wrong: Order is just an interface
import { Order } from "./order.js";
error TS1484: 'Order' is a type and must be imported using a type-only import
when 'verbatimModuleSyntax' is enabled.
// Correct
import type { Order } from "./order.js";
import { totalItems } from "./order.js";
isolatedModules ensures each file can be compiled independently. Tools like esbuild, swc, and Vitest all process files individually, so this flag catches errors before they cause issues.
You might have seen esModuleInterop in older guides. With module: NodeNext, this flag is enabled by default, so you don’t need to declare it.
6. Separating Typecheck and Build Configurations
The base tsconfig.json serves the editor and the typecheck command. It includes tests. When building, you don’t want test files in dist. So create a separate tsconfig.build.json:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
},
"include": ["src"],
"exclude": ["**/*.test.ts"]
}
extends inherits all compilerOptions from the base file. Within the child file, include and exclude completely replace values from the parent—they don’t merge. Run the build command:
npm run build
ls dist
index.js index.js.map order.js order.js.map
The dist folder contains only application code, placed at the root thanks to rootDir: "src".
6.1. Writing Tests with Vitest
Install Vitest:
npm install --save-dev vitest
Add "test": "vitest run" to scripts, then write a test for the totalItems function:
// tests/order.test.ts
import { describe, expect, it } from "vitest";
import type { Order } from "../src/order.js";
import { totalItems } from "../src/order.js";
describe("totalItems", () => {
it("counts items correctly in an order", () => {
const order: Order = { id: "A1", items: ["pho", "tra-da", "quay"] };
expect(totalItems(order)).toBe(3);
});
it("returns 0 for an empty order", () => {
expect(totalItems({ id: "A2", items: [] })).toBe(0);
});
});
npm run typecheck && npm test
✓ tests/order.test.ts (2 tests)
Test Files 1 passed (1)
Tests 2 passed (2)
Vitest strips types when running without type-checking. Always run typecheck in CI to catch type errors. Otherwise, a test with type errors can still “pass”.
7. Variants for Projects Using Bundlers
If code is bundled by Vite, esbuild, or webpack instead of running directly on Node.js, change the module section:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"jsx": "react-jsx"
}
}
moduleResolution: "Bundler"allows imports without file extensions, since the bundler resolves them.noEmit: truemeanstsconly type-checks; the bundler handles file output.- Include
"DOM"since code runs in a browser. Thejsxline is only needed for React.
Note: Don’t use
Bundlerfor code running directly vianode dist/index.js. The compiler accepts imports without.js, but Node.js will error at runtime.
8. Common Mistakes
Using paths to create aliases and then building with tsc. Suppose you declare "paths": { "@/*": ["src/*"] }. Your editor understands the alias, but tsc doesn’t rewrite paths in the output, so node can’t find @/order.js. For Node.js projects, use subpath imports in package.json:
{
"imports": {
"#src/*": "./dist/*"
}
}
Then import with import { totalItems } from "#src/order.js";. In NodeNext mode, TypeScript reads the imports field and maps back from dist to src, so both editor and runtime resolve correctly.
Overusing skipLibCheck. This flag only skips type-checking .d.ts files, making builds faster and avoiding errors from library types. It doesn’t make your code less safe. However, if you write .d.ts files yourself, remember that errors within those files will also be skipped.
Forgetting include. Without include, TypeScript scans the entire directory, including dist. This often produces the error TS5055: Cannot write file ... because it would overwrite input file.
Mixing CommonJS and ESM. If package.json lacks "type": "module" but you use NodeNext, .ts files compile to CommonJS. Importing an ESM-only package then causes errors. Pick one approach from the start. For new projects, choose ESM.
Enabling strict then scattering any everywhere. This defeats the purpose of strict mode. When unsure of a type, use unknown and narrow it with checks.
9. Conclusion
This article built a tsconfig.json for a new Node.js project with:
moduleandmoduleResolutionset toNodeNext, paired with"type": "module", so the compiler mimics the runtime correctly.strictplusnoUncheckedIndexedAccessandexactOptionalPropertyTypesto catch errors early.verbatimModuleSyntaxandisolatedModulesfor predictable output and compatibility with fast compilers.- A separate
tsconfig.build.jsonsodistcontains only application code.
For bundler-based projects, just switch to moduleResolution: "Bundler" and noEmit: true. The strict checking flags remain unchanged. Finally, always run tsc --noEmit in CI, since most test runners and bundlers don’t type-check for you.