Create your own
Lesson illustration

Configuring a Strict pnpm Node.js TypeScript Project with ESM, ESLint, Prettier, and Vitest

Hello, and welcome. This course builds a working Windows-oriented process-inspection CLI incrementally, but it starts with the engineering constraints that keep such a tool safe to evolve: explicit boundaries, reliable checks, and a repeatable development environment.

In this first module, we establish the TypeScript baseline that every later vertical slice will use. By the end of this lesson, you will have an ESM-first Node.js project managed by pnpm, strict TypeScript checking, ESLint for code-quality rules, Prettier for mechanical formatting, and Vitest for fast tests. The project will run TypeScript in development through tsx; later, when the Frida host and injected agent need different build targets, we will introduce that deliberately rather than overloading this initial configuration.


Make one deliberate toolchain decision

For this project, use:

  • Node.js 22 LTS or newer as the runtime.
  • pnpm 10 for deterministic dependency installation.
  • ES modules (ESM) as the module system.
  • TypeScript as a type checker, with tsx transpiling and executing source during development.
  • Vitest for tests.
  • ESLint flat config plus typescript-eslint for static analysis.
  • Prettier as the sole formatter.

This separates responsibilities cleanly:

ToolResponsibilityNot responsible for
pnpmDependency graph, lockfile, package scriptsType checking or formatting
TypeScriptType correctness and module checkingExecuting the application
tsxFast local execution of .ts entry pointsFull type checking
ESLintSuspicious patterns and maintainability rulesLayout and whitespace
PrettierConsistent mechanical formattingSemantic code-quality decisions
VitestTest execution and assertionsProduction packaging

Keeping TypeScript’s checker independent from the development runner matters. A command can execute under a transpiler even when it contains a type error; pnpm typecheck remains the authoritative check.

ESM is also a practical choice for the eventual host application and agent bundle. It gives the project one import/export vocabulary from the start, rather than carrying CommonJS compatibility decisions throughout every feature.

From CommonJS to ES modules (ESM) in TypeScript

Watch “From CommonJS to ES modules (ESM) in TypeScript” by TypeScript with Benny Code for a concise explanation of what changes when a Node project adopts ESM.

Watch the context for the relationship between CommonJS and standardized ESM. Then watch the migration, focusing on the three aligned decisions: ESM syntax in source, an ESM compiler mode, and the package-level "type": "module" declaration. The video uses a simple compiler-emission setup; our project will instead use tsx while developing, so the emitted-output details will differ.

A key distinction will prevent several confusing configuration mistakes:

  1. package.json tells Node how to interpret JavaScript files at runtime.
  2. tsconfig.json tells TypeScript how to type-check TypeScript files.
  3. The tool executing the program determines whether TypeScript must first be emitted as JavaScript.

Our development command runs TypeScript through tsx, so TypeScript should not emit JavaScript beside the source. This makes noEmit: true correct for now.

Documentation - Modules - Choosing Compiler Options

Read TypeScript’s official module-configuration guidance to distinguish a bundler-style type-checking setup from a configuration where tsc emits JavaScript that Node runs directly.

In the section “I’m compiling and running the outputs in Node.js,” read the Node ESM note. Also inspect both configuration blocks in this section. Notice that the page recommends NodeNext when TypeScript itself emits JavaScript for Node, whereas the earlier ESNext plus Bundler example is appropriate when another tool performs transpilation or bundling. We will use the latter arrangement because tsx owns development-time transpilation and a later build step will own packaging.


Scaffold the repository

Open PowerShell in the directory where you keep development projects. First confirm that Node is recent enough and enable Corepack, which lets the repository declare its pnpm version.

node --version
corepack enable

mkdir process-lens
cd process-lens

pnpm init
pnpm add --save-dev typescript tsx @types/node vitest eslint @eslint/js typescript-eslint prettier eslint-config-prettier
mkdir src, test

Replace the generated package.json with the following. The precise pnpm patch version is less important than committing both this declaration and the generated pnpm-lock.yaml; together, they let another machine install the same dependency graph.

{
  "name": "process-lens",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@10.6.2",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "dev": "tsx src/main.ts",
    "typecheck": "tsc --project tsconfig.json",
    "lint": "eslint . --max-warnings=0",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

A few choices here are intentional:

  • "private": true prevents an accidental pnpm publish while this is an application, not a public package.
  • "type": "module" means .js files in this package are ESM by default. Therefore, use import and export, not require and module.exports.
  • The scripts name the engineering feedback loop explicitly. In continuous integration, typecheck, lint, format:check, and test should all run.
  • There is intentionally no "build" script yet. A single generic build would be misleading because the Node host and Frida agent will eventually need distinct build behavior.

Add a focused .gitignore:

node_modules/
dist/
coverage/
*.tsbuildinfo
.env

Commit package.json, pnpm-lock.yaml, and configuration files to version control. Do not commit node_modules; pnpm can restore it from the manifest and lockfile.


Configure strict TypeScript

Create tsconfig.json at the repository root:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],

    "module": "ESNext",
    "moduleResolution": "Bundler",
    "moduleDetection": "force",
    "verbatimModuleSyntax": true,
    "isolatedModules": true,

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "useUnknownInCatchVariables": true,
    "noFallthroughCasesInSwitch": true,

    "noEmit": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"],
  "exclude": ["node_modules", "dist", "coverage"]
}

This is deliberately stricter than TypeScript’s default. It makes uncertainty visible near the code that introduces it, instead of allowing it to leak into later layers such as CLI rendering, Frida message handling, or recording.

The important compiler options

strict: true enables a family of soundness checks, including strictNullChecks. A value typed as string | undefined cannot silently be used as a string. That is essential in a process-inspection application, where modules, exports, process metadata, and pointer-derived data may be absent.

noUncheckedIndexedAccess: true treats indexed lookups as potentially missing. For example, an array access becomes T | undefined, because an index can be out of range. This will be valuable when working with command arguments and decoded native parameters.

exactOptionalPropertyTypes: true distinguishes an omitted optional field from a field explicitly set to undefined. That distinction becomes meaningful for command options and persisted recording envelopes.

useUnknownInCatchVariables: true prevents treating arbitrary thrown values as Error objects. JavaScript allows throw "failed" and throw { code: 1 }; later lessons will turn such unknown failures into typed application errors.

moduleDetection: "force" ensures every source file is treated as a module, avoiding accidental globals.

verbatimModuleSyntax: true preserves the import/export syntax you wrote instead of allowing TypeScript to rewrite it in surprising ways. It also encourages an honest distinction between runtime dependencies and type-only dependencies:

import type { SessionId } from './session-id.js';

A type-only import disappears at runtime. A normal import must refer to a real runtime module.

Although our source files are .ts, use .js in relative import specifiers:

import { normalizeLabel } from './normalize-label.js';

tsx and the TypeScript bundler resolver can map this to normalize-label.ts during development. The source remains compatible with a future emitted ESM build, where the runtime file really will be normalize-label.js.

noEmit: true turns tsc into a pure checker. tsx runs the development command; TypeScript checks it. This avoids generated JavaScript mixing with source files.

skipLibCheck: true skips deep checking of declaration files inside dependencies. It does not weaken checking of your project; it prevents a third-party declaration issue from blocking everyday feedback.

The TSConfig Cheat Sheet

Watch the selected portion of “The TSConfig Cheat Sheet” by Matt Pocock to connect the configuration choices to the development workflow.

Watch the foundations for target, module-related settings, skipLibCheck, and module detection. Then watch strictness, paying particular attention to indexed access. Finish with the build choice: distinguish the NodeNext setup used when tsc emits runnable Node output from an ESNext plus Bundler setup used with a separate transpiler such as tsx.


Add a tiny executable slice and test

Before configuring the remaining tools, give them one real module to inspect. Create src/normalize-label.ts:

export function normalizeLabel(value: string): string {
  return value.trim();
}

Then create src/main.ts:

import { normalizeLabel } from './normalize-label.js';

const targetPlatform = normalizeLabel('Windows');

console.log(`Process Lens ready for ${targetPlatform}.`);

Run it:

pnpm dev

Expected output:

Process Lens ready for Windows.

Now create test/normalize-label.test.ts:

import { describe, expect, it } from 'vitest';

import { normalizeLabel } from '../src/normalize-label.js';

describe('normalizeLabel', () => {
  it('removes leading and trailing whitespace', () => {
    expect(normalizeLabel('  explorer.exe  ')).toBe('explorer.exe');
  });
});

Create vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: false,
    include: ['test/**/*.test.ts']
  }
});

The explicit imports from vitest preserve TypeScript’s ability to identify dependencies and keep tests free of hidden global state. The include pattern ensures that test discovery remains predictable as the repository gains Frida fixture projects and generated agent artifacts.

Run the test suite:

pnpm test

At this stage, the test is intentionally small. Its purpose is to prove that TypeScript source, ESM imports, and the Vitest runner agree. In the next lessons, tests will become more architectural: application services will be tested with fakes rather than real Frida or terminal dependencies.


Add ESLint and Prettier without overlapping responsibilities

ESLint now uses a flat configuration model. Because this package has "type": "module", eslint.config.js is itself an ESM file.

Getting Started | typescript-eslint

Read the typescript-eslint quickstart for the flat-config installation pattern and recommended configurations that will form our linting baseline.

In “Quickstart,” begin with installation and setup, then inspect the eslint.config.mjs example immediately following that text. In “Details,” note why .mjs forces ESM and why a package with "type": "module" may instead use eslint.config.js. Finally, in “Additional Configs,” read the strict and stylistic recommendation. We will use those configurations but place Prettier’s compatibility config last so ESLint does not argue about formatting.

Create eslint.config.js:

import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';

export default defineConfig(
  {
    ignores: ['coverage/', 'dist/', 'node_modules/']
  },
  {
    files: ['src/**/*.ts', 'test/**/*.ts', 'vitest.config.ts'],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      tseslint.configs.strict,
      tseslint.configs.stylistic,
      eslintConfigPrettier
    ]
  }
);

The ordering is important:

  1. JavaScript recommended rules establish a baseline.
  2. typescript-eslint recommended and strict rules add TypeScript-aware analysis.
  3. Stylistic rules are loaded for sensible TypeScript conventions.
  4. eslint-config-prettier is last, disabling stylistic rules that conflict with Prettier.

Create .prettierrc.json:

{
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "semi": true
}

These are team conventions, not correctness rules. The important part is that a formatter owns them consistently. If a formatting preference changes later, update this one file and reformat the repository; do not create competing ESLint formatting rules.

Run formatting once, then run the checks:

pnpm format
pnpm typecheck
pnpm lint
pnpm format:check
pnpm test

A clean run means five independent pieces agree:

CommandWhat success establishes
pnpm devThe ESM development entry point executes
pnpm typecheckSource and test types satisfy strict compiler settings
pnpm lintCode passes static-analysis rules
pnpm format:checkRepository formatting matches the declared convention
pnpm testThe test runner can execute ESM TypeScript tests

A baseline worth preserving

You now have a small but production-oriented TypeScript foundation:

  • pnpm manages reproducible dependencies through pnpm-lock.yaml.
  • The package is explicitly ESM through "type": "module".
  • tsx executes TypeScript during development, while tsc --noEmit remains the strict type checker.
  • Strict settings make absence, optional data, unsafe indexing, and unknown exceptions visible.
  • ESLint identifies maintainability and correctness concerns without duplicating formatter rules.
  • Prettier provides deterministic formatting.
  • Vitest confirms the test environment is operational.

Keep the dependency direction simple even at this early stage: source code should not import from test, configuration should not become application logic, and the eventual composition root will stay separate from domain behavior.

Next, we will use the strict baseline to model process IDs, session IDs, commands, and instrumentation states with branded types, immutable data, and discriminated unions.

Can't find a good explanation? Sign up and we'll make it for you

Sign up