TypeScript Node.js Project Setup: A Minimal Modern Starter
Updated October 2026. The 2024 version of this post used
ts-nodeand an ESM loader flag. Current Node runs TypeScript files natively, which removes most of that setup.
The short version: on Node 24 LTS, node src/main.ts works with no flags. Node strips the type annotations and runs the result. It does not type-check, so tsc becomes a checker, not a runner. Watch mode and .env loading are built into Node too.
| Concern | Tool |
|---|---|
| Run TypeScript | node (type stripping) |
| Reload on change | node --watch |
| Environment variables | node --env-file-if-exists=.env |
| Type checking | tsc --noEmit |
| Lint | ESLint flat config + typescript-eslint |
| Format | Prettier |
| Test | Vitest |
| Packages | pnpm |
Node version facts
| Node version | Type stripping |
|---|---|
| 22.6 | Added behind --experimental-strip-types |
| 23.6, 22.18 | Enabled by default, no flag needed |
| 24.3, 22.18 | No more experimental warning |
| 25.2, 24.12 | Marked stable |
Use Node 24 or newer. Node 22.18+ works too. Source: the Node.js TypeScript docs.
Tested with Node 24.14, pnpm 10.33, TypeScript 6.0, ESLint 10.11, typescript-eslint 8.71, Vitest 5.0, and Prettier 3.9.
1. Create the project
1
2
3
4
5
mkdir my-service && cd my-service
git init
pnpm init
pnpm pkg set type=module
pnpm add -D typescript@~6.0 @types/node@24 eslint @eslint/js typescript-eslint eslint-config-prettier prettier vitest
type: module makes .ts files run as ES modules. Match @types/node to the Node major version you deploy on.
TypeScript 7 is out, but typescript-eslint 8.71 supports only
typescript >=4.8.4 <6.1.0. Pin TypeScript 6.0 until typescript-eslint supports 7.
2. tsconfig.json
Node ignores tsconfig.json. This file exists for the type checker and the editor, and it is set up to reject TypeScript that Node cannot run.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"types": ["node"],
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"rewriteRelativeImportExtensions": true,
"resolveJsonModule": true
},
"include": ["src"]
}
The options that matter for type stripping:
erasableSyntaxOnlyerrors on syntax that has runtime behavior:enum, namespaces with code, constructor parameter properties, andimport x = require(). Node rejects all of these at runtime. Useas constobjects instead of enums.verbatimModuleSyntaxrequiresimport typefor type-only imports. Without it,import { User } from './user.ts'type-checks but fails in Node with “does not provide an export named ‘User’”.rewriteRelativeImportExtensionslets you write./greet.tsin imports, which Node requires, and rewrites them to.jsif you ever compile.noUncheckedIndexedAccesstypesarr[i]asT | undefined. It is not part ofstrict, and it catches real bugs.
3. Write code with .ts imports
1
2
3
4
5
6
// src/greet.ts
export type Greeting = { name: string };
export function greet({ name }: Greeting): string {
return `Hello, ${name}`;
}
1
2
3
4
5
// src/main.ts
import { greet, type Greeting } from './greet.ts';
const user: Greeting = { name: process.env.USER_NAME ?? 'world' };
console.log(greet(user));
1
node src/main.ts
4. ESLint flat config
1
2
3
4
5
6
7
8
9
10
11
12
// eslint.config.js
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import prettier from 'eslint-config-prettier/flat';
import tseslint from 'typescript-eslint';
export default defineConfig(
{ ignores: ['dist/'] },
eslint.configs.recommended,
tseslint.configs.recommended,
prettier,
);
prettier goes last so it turns off the formatting rules that conflict with Prettier. For rules that need type information (floating promises, unsafe any), switch to tseslint.configs.recommendedTypeChecked and enable parserOptions.projectService. It is slower but catches more.
5. Prettier
1
2
3
4
{
"singleQuote": true,
"trailingComma": "all"
}
Save that as .prettierrc, and list generated files in .prettierignore:
1
2
dist
pnpm-lock.yaml
6. Tests
Vitest runs TypeScript without configuration:
1
2
3
4
5
6
7
8
9
// src/greet.test.ts
import { describe, expect, it } from 'vitest';
import { greet } from './greet.ts';
describe('greet', () => {
it('greets by name', () => {
expect(greet({ name: 'Ada' })).toBe('Hello, Ada');
});
});
7. Scripts
1
2
3
4
5
6
7
8
9
10
11
{
"scripts": {
"dev": "node --watch --env-file-if-exists=.env src/main.ts",
"start": "node src/main.ts",
"typecheck": "tsc",
"lint": "eslint .",
"format": "prettier --write .",
"test": "vitest run",
"check": "pnpm typecheck && pnpm lint && prettier --check . && pnpm test"
}
}
tsc emits nothing here because of noEmit. Run pnpm check in CI.
Optional: compile to JavaScript
A service can run its .ts files directly in production. A library cannot: Node refuses to strip types from files under node_modules, so packages must ship JavaScript. Add a tsconfig.build.json:
1
2
3
4
5
6
7
8
9
10
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "src",
"outDir": "dist",
"sourceMap": true
},
"exclude": ["src/**/*.test.ts"]
}
Then tsc -p tsconfig.build.json writes dist/, with ./greet.ts imports rewritten to ./greet.js.
When to use tsx instead
Native type stripping has limits. Use tsx (pnpm add -D tsx, then tsx watch src/main.ts) when you need:
- enums, decorators, or parameter properties, for example in an existing NestJS-style codebase
pathsaliases fromtsconfig.json, which Node does not read- extensionless imports
- a Node version older than 22.18
tsx is also the simpler replacement for ts-node in older projects. Like Node, it does not type-check, so keep tsc in check.
