Summary #
For a modern, simple TypeScript CLI project in late 2025, the winning combination is tsx for TypeScript execution and Vitest for testing. This setup requires minimal configuration while providing excellent developer experience.
tsx (11.6k GitHub stars, 1,838 dependent projects)[1] is a zero-config TypeScript runner built on esbuild. It "just works" without requiring a tsconfig.json, handles ESM/CJS seamlessly, and supports watch mode out of the box. While Node.js 22+ now has native TypeScript support via type stripping[2], tsx remains the better choice for CLI development because native support has significant limitations: no enums without extra flags, no tsconfig features like path aliases, and the native approach is still considered experimental for production[3].
Vitest (15.4k GitHub stars, ~18.5M weekly downloads)[4] has become the de facto standard for TypeScript testing. It offers native TypeScript/ESM support, Jest-compatible APIs, and runs 10-20x faster than Jest in watch mode[5]. For Node.js CLI applications, Vitest provides a cleaner experience than Node's built-in test runner, which lacks TypeScript documentation and requires additional loader configuration[6].
Philosophy & Mental Model #
The core philosophy is separation of concerns with minimal tooling:
-
tsx handles execution only—it transpiles and runs TypeScript instantly without type checking. This is a feature, not a limitation: you get instant feedback during development.
-
tsc (TypeScript compiler) handles type checking as a separate step. Run it in CI or as a pre-commit hook. This separation means you're never blocked by type errors during iteration.
-
Vitest handles testing with the same fast, esbuild-powered transpilation approach as tsx.
-
mise orchestrates everything through tasks, replacing npm scripts with a more powerful task runner that includes environment management.
The mental model: Write TypeScript → Run instantly with tsx → Test with Vitest → Type-check with tsc before committing.
Setup #
Step 1: Initialize the project with mise #
Update your mise.toml to include tasks and the node_modules bin path:
1[env]
2_.path = ["./node_modules/.bin"]
3
4[tools]
5node = "22"
6
7[tasks.dev]
8description = "Run in development mode with watch"
9run = "tsx watch src/index.ts"
10
11[tasks.test]
12description = "Run tests"
13run = "vitest"
14
15[tasks.test-run]
16description = "Run tests once"
17run = "vitest run"
18
19[tasks.typecheck]
20description = "Type check with tsc"
21run = "tsc --noEmit"
22
23[tasks.build]
24description = "Build for production"
25run = "tsdown src/index.ts"
Step 2: Initialize npm and install dependencies #
1npm init -y
2npm install -D typescript tsx vitest @types/node
Step 3: Create tsconfig.json #
1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "NodeNext",
5 "moduleResolution": "NodeNext",
6 "strict": true,
7 "esModuleInterop": true,
8 "skipLibCheck": true,
9 "noEmit": true,
10 "outDir": "dist",
11 "rootDir": "src",
12 "declaration": true,
13 "resolveJsonModule": true,
14 "isolatedModules": true
15 },
16 "include": ["src/**/*"],
17 "exclude": ["node_modules", "dist"]
18}
Step 4: Create vitest.config.ts #
1import { defineConfig } from 'vitest/config'
2
3export default defineConfig({
4 test: {
5 globals: true,
6 environment: 'node',
7 include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
8 },
9})
Step 5: Create project structure #
1mkdir -p src tests
Create src/index.ts:
1export function main(): void {
2 console.log('Hello from CLI!')
3}
4
5main()
Create src/example.test.ts:
1import { describe, it, expect } from 'vitest'
2
3describe('example', () => {
4 it('should work', () => {
5 expect(1 + 1).toBe(2)
6 })
7})
Step 6: Update package.json #
Add the type field and basic scripts as fallback:
1{
2 "type": "module",
3 "scripts": {
4 "dev": "tsx watch src/index.ts",
5 "test": "vitest",
6 "typecheck": "tsc --noEmit"
7 }
8}
Core Usage Patterns #
Pattern 1: Running TypeScript Files #
1# Run a file directly
2tsx src/index.ts
3
4# Run with watch mode (restarts on changes)
5tsx watch src/index.ts
6
7# Or use mise tasks
8mise run dev
Pattern 2: Writing Tests #
1// src/utils.ts
2export function parseArgs(args: string[]): Record<string, string> {
3 const result: Record<string, string> = {}
4 for (let i = 0; i < args.length; i += 2) {
5 const key = args[i]?.replace(/^--/, '')
6 const value = args[i + 1]
7 if (key && value) {
8 result[key] = value
9 }
10 }
11 return result
12}
13
14// src/utils.test.ts
15import { describe, it, expect } from 'vitest'
16import { parseArgs } from './utils.js'
17
18describe('parseArgs', () => {
19 it('parses key-value pairs', () => {
20 const result = parseArgs(['--name', 'alice', '--age', '30'])
21 expect(result).toEqual({ name: 'alice', age: '30' })
22 })
23
24 it('handles empty input', () => {
25 expect(parseArgs([])).toEqual({})
26 })
27})
Pattern 3: Watch Mode Testing #
1# Interactive watch mode (default)
2vitest
3
4# Run once and exit
5vitest run
6
7# Run specific test file
8vitest run src/utils.test.ts
9
10# With coverage
11vitest run --coverage
Pattern 4: Type Checking Separately #
1# Check types without emitting files
2tsc --noEmit
3
4# Watch mode type checking
5tsc --noEmit --watch
6
7# Use mise task
8mise run typecheck
Pattern 5: CLI Entry Point Pattern #
1// src/cli.ts
2import { parseArgs } from 'node:util'
3
4interface Options {
5 help: boolean
6 version: boolean
7 config?: string
8}
9
10export function cli(args: string[]): void {
11 const { values } = parseArgs({
12 args,
13 options: {
14 help: { type: 'boolean', short: 'h', default: false },
15 version: { type: 'boolean', short: 'v', default: false },
16 config: { type: 'string', short: 'c' },
17 },
18 strict: true,
19 })
20
21 if (values.help) {
22 console.log('Usage: mycli [options]')
23 return
24 }
25
26 if (values.version) {
27 console.log('1.0.0')
28 return
29 }
30
31 // Main logic here
32}
33
34// Run if this is the entry point
35cli(process.argv.slice(2))
Anti-Patterns & Pitfalls #
Don't: Use relative imports without .js extension #
1// Bad - may cause issues with ESM
2import { helper } from './helper'
Why it's wrong: ESM requires explicit file extensions. TypeScript's module resolution uses .js even for .ts files because that's what they compile to.
Instead: Always use .js extensions in imports #
1// Good - works correctly with ESM
2import { helper } from './helper.js'
Don't: Mix tsx and tsc for execution #
1// Bad workflow
2tsc && node dist/index.js // Slow, unnecessary for development
Why it's wrong: You lose the instant feedback of tsx, and you're maintaining compiled output unnecessarily during development.
Instead: Use tsx for development, tsc only for type checking #
1# Development
2tsx src/index.ts
3
4# Type check in CI or pre-commit
5tsc --noEmit
Don't: Use Jest-style globals without configuring Vitest #
1// Bad - will fail without globals: true in config
2describe('test', () => { // Error: describe is not defined
3 it('works', () => {})
4})
Why it's wrong: Vitest doesn't inject globals by default.
Instead: Either import from vitest or enable globals #
1// Option 1: Explicit imports (recommended for clarity)
2import { describe, it, expect } from 'vitest'
3
4// Option 2: Enable globals in vitest.config.ts
5// test: { globals: true }
Don't: Put test files in a separate tests/ directory by default #
// Anti-pattern structure
src/
utils.ts
tests/
utils.test.ts // Far from the code it tests
Why it's problematic: Tests become disconnected from the code they test, making refactoring harder.
Instead: Colocate tests with source files #
// Better structure
src/
utils.ts
utils.test.ts // Right next to the code
Don't: Skip type checking entirely #
1# Bad - no type safety verification
2tsx src/index.ts && npm publish
Why it's wrong: tsx doesn't type check. You could ship code with type errors.
Instead: Add type checking to your CI/pre-commit #
1# In CI or pre-commit hook
2tsc --noEmit && vitest run
Caveats #
-
No bundling included: This setup is for development and running TypeScript directly. If you need to distribute a compiled CLI (e.g., to npm), add
tsdownfor bundling:npm install -D tsdown[7]. -
tsx doesn't type check: This is by design for speed, but means you must run
tsc --noEmitseparately to catch type errors. Always include this in CI. -
ESM is the default: With
"type": "module"in package.json, all.tsfiles are treated as ESM. Use.ctsextension or"type": "commonjs"if you need CommonJS. -
Node.js native TypeScript is not recommended yet: While Node 22+ supports TypeScript natively via
--experimental-strip-types, it has limitations (no enums, no path aliases, no tsconfig features) and is still considered experimental for production[8]. -
Vitest's frontend focus: Most Vitest documentation assumes frontend/Vite usage. For pure Node.js CLI testing, ignore the DOM/browser-related features[9].
-
Watch mode memory: Both tsx and Vitest watch modes keep processes running. For large projects, this can consume significant memory.
References #
[1] tsx GitHub Repository - GitHub stars, feature overview, and documentation
[2] Node.js Running TypeScript Natively - Official Node.js TypeScript documentation
[3] Node.js TypeScript API Documentation - Recommended tsconfig settings and feature limitations
[4] Vitest GitHub Repository - GitHub stars and release information
[5] Vitest vs Jest Comparison - Performance benchmarks and feature comparison
[6] Node Test Runner vs Vitest Discussion - Community discussion on native test runner limitations
[7] tsdown Introduction - Modern TypeScript bundler replacing tsup, built on Rolldown
[8] Node's Built-in TypeScript Support - Dr. Axel Rauschmayer's analysis of limitations
[9] Vitest Getting Started - Official documentation, note frontend-focused examples
[10] mise Node.js Cookbook - Task configuration examples for Node.js projects
[11] TypeScript in 2025 ESM/CJS Publishing - Best practices for TypeScript library publishing