crefft Source 2026.10.4

TypeScript Style Guide

crefft > Languages > TypeScript

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Extends Google TypeScript Style Guide.

Quick Reference

TaskToolCommand
LintBiome1biome check .
FormatBiome1biome format --write .
Type checktsc2tsc --noEmit
Semantic--
Dead codets-prune3ts-prune
Coveragec84c8 vitest
Complexity-via Biome1
Fuzz--
Test perfVitest5vitest doctor

Linting & Formatting: Biome

Projects MUST use Biome1 for linting and formatting unless extensive plugin ecosystems are required (see ESLint Alternative below).

Why Biome

  • Performance: 20x faster than ESLint6+Prettier7
  • Unified tooling: Combines linting, formatting, and import organization in one tool
  • Zero config: Works out of the box with sensible defaults
  • Better errors: Provides more actionable error messages
  • Native binary: No JavaScript runtime overhead
# Install (exact version)
npm install --save-dev --save-exact @biomejs/biome@2.5.12

# Initialize config
npx @biomejs/biome@2.5.12 init

# Lint and format
npx @biomejs/biome@2.5.12 check --write .

# CI mode
npx @biomejs/biome@2.5.12 ci .

# Update an older configuration after a Biome upgrade
npx @biomejs/biome@2.5.12 migrate --write

Projects MUST pin Biome to an exact version. Biome validates biome.json against the schema version recorded in $schema and rejects unknown keys, so a floating install silently pairs a new binary with an old configuration and fails the whole run.

Configuration (biome.json)

The schema version MUST match the installed Biome version. Biome 2 moved import sorting out of the linter into the assist actions, so organizeImports at the top level is now an unknown key and is a fatal configuration error. Biome 2.5 also replaced linter.rules.recommended with linter.rules.preset.

{
  "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
  "assist": {
    "enabled": true,
    "actions": {
      "source": {
        "organizeImports": "on"
      }
    }
  },
  "linter": {
    "enabled": true,
    "rules": {
      "preset": "recommended",
      "complexity": {
        "noExcessiveCognitiveComplexity": {
          "level": "warn",
          "options": { "maxAllowedComplexity": 15 }
        }
      },
      "suspicious": {
        "noExplicitAny": "error"
      },
      "style": {
        "useConst": "error",
        "noNonNullAssertion": "warn"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "always"
    }
  }
}

ESLint Alternative

Projects MAY use ESLint6 for projects requiring extensive plugin ecosystems.

ESLint 10 removed .eslintrc.* support entirely: the only configuration format is flat config in eslint.config.*. An eslintrc-style object with parser, plugins, extends, and parserOptions is not read at all, and ESLint exits with “couldn’t find an eslint.config.* file”.

typescript-eslint 8.70.0 declares peerDependencies.typescript as >=4.8.4 <6.1.0, so it MUST NOT be installed alongside TypeScript 7. Projects that need ESLint MUST stay on TypeScript 6.0.3 until typescript-eslint publishes a release that accepts TypeScript 7; installing with --force or --legacy-peer-deps produces a parser running against an unsupported compiler API.

npm install --save-dev --save-exact \
  eslint@10.10.0 typescript-eslint@8.70.0 @eslint/js@10.0.1 typescript@6.0.3
// eslint.config.mjs
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  tseslint.configs.strictTypeChecked,
  tseslint.configs.stylisticTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
  { ignores: ['dist/', 'eslint.config.mjs'] },
);

projectService: true replaces parserOptions.project: it lets the parser ask the TypeScript project service for each file’s program instead of requiring every linted file to be listed in a tsconfig.json include.

Type Checking: tsc

Projects MUST enable strict type checking with TypeScript2’s compiler.

Why tsc

  • Official compiler: The authoritative TypeScript type checker
  • Strict mode: Catches entire classes of bugs at compile time
  • IDE integration: Provides real-time type checking in editors
  • Zero-cost abstraction: Type checking happens at build time with no runtime overhead
# Check types without emitting
npx --package=typescript@6.0.3 tsc --noEmit

# Watch mode
npx --package=typescript@6.0.3 tsc --noEmit --watch

tsconfig.json (Strict)

Projects MUST enable all strict type checking options:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true
  }
}

Decorators

Projects MAY use decorators for cross-cutting concerns like logging, validation, and dependency injection. TypeScript 5.0+ supports ECMAScript decorators natively.

Why Decorators

  • Separation of concerns: Keep business logic clean by extracting cross-cutting behavior
  • Reusability: Apply common patterns (logging, caching, authorization) declaratively
  • Metaprogramming: Modify or extend class behavior at design time
  • Framework support: Required for frameworks like NestJS8, TypeORM9, and Angular10

Enabling Decorators

TypeScript implements two decorator systems and a project MUST choose exactly one for the whole program. They are not interoperable.

ModeCompiler flagsDecorator signatureParameter decorators
Standard (TC39, TypeScript 5.0+)none(target, context)Not supported
Legacy (experimental)experimentalDecorators(target, key, descriptor)Supported

Why the modes must be kept apart

experimentalDecorators is a program-wide switch, not a per-file opt-in. Compiling the standard-decorator examples below under that flag makes the compiler reject every one of them with TS1238, TS1240, TS1241, TS1270, and TS1271. emitDecoratorMetadata MUST NOT be enabled outside legacy mode: it is only implemented for the experimental system and has no standard equivalent.

Standard decorators (tsconfig.json) — no decorator flags at all:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true
  }
}

Legacy decorators (tsconfig.json) — required by NestJS8, TypeORM9, and Angular10:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Running decorated code under Vitest

Vite11 8’s oxc transform implements legacy decorators only, auto-detected from tsconfig.json. Standard-decorator sources reach Node untransformed and fail with SyntaxError, so projects on standard decorators MUST compile with tsc before running Vitest5:

npx --package=typescript@6.0.3 tsc -p tsconfig.json && npx vitest@5.0.0 run dist

Class Decorators

Standard mode. A class decorator receives the class and a ClassDecoratorContext, and MAY return a replacement class.

function Singleton<T extends new (...args: any[]) => object>(
  target: T,
  _context: ClassDecoratorContext,
) {
  let instance: InstanceType<T> | undefined;
  return class extends target {
    constructor(...args: any[]) {
      if (instance) return instance;
      super(...args);
      instance = this as InstanceType<T>;
    }
  };
}

@Singleton
class Database {
  constructor(readonly url: string) {}
}

// Same instance; the second constructor's arguments are discarded
const db1 = new Database('postgres://...');
const db2 = new Database('mysql://...');
console.log(db1 === db2); // true
console.log(db2.url); // 'postgres://...'

Method Decorators

Standard mode. Type the wrapper generically so the decorated method keeps its signature; any erases the return type at every call site.

function Log<This, Args extends unknown[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) {
  const methodName = String(context.name);
  return function (this: This, ...args: Args): Return {
    console.log(`[${methodName}] called with:`, args);
    const result = target.call(this, ...args);
    console.log(`[${methodName}] returned:`, result);
    return result;
  };
}

class Calculator {
  @Log
  add(a: number, b: number): number {
    return a + b;
  }
}

Accessor Decorators

Standard mode. Validation MUST be applied through an accessor decorator, not a field decorator. A field decorator’s initialiser runs once, so it cannot see any later assignment.

Don’t — validates only the declared initial value:

function Validate(min: number, max: number) {
  return function (_target: undefined, _context: ClassFieldDecoratorContext) {
    return function (initialValue: number): number {
      if (initialValue < min || initialValue > max) throw new Error('out of range');
      return initialValue;
    };
  };
}

class Product {
  @Validate(0, 100)
  quantity = 10;
}

const p = new Product();
p.quantity = 5000; // accepted: the decorator never runs again

Do — accessor gives the decorator both init and set:

function Range(min: number, max: number) {
  return function <This>(
    target: ClassAccessorDecoratorTarget<This, number>,
    context: ClassAccessorDecoratorContext<This, number>,
  ): ClassAccessorDecoratorResult<This, number> {
    const name = String(context.name);
    const check = (value: number): number => {
      if (!Number.isFinite(value) || value < min || value > max) {
        throw new RangeError(`${name} must be between ${min} and ${max}`);
      }
      return value;
    };
    return {
      get(this: This) {
        return target.get.call(this);
      },
      set(this: This, value: number) {
        target.set.call(this, check(value));
      },
      init: check,
    };
  };
}

class Product {
  @Range(0, 100) accessor quantity = 10;
}

const p = new Product();
p.quantity = 5000; // throws RangeError

Legacy Decorators (NestJS, TypeORM)

These are framework-provided legacy decorators, not standard ones. They MUST be compiled with the legacy tsconfig.json shown above; the standard decorators in this guide MUST NOT appear in the same program.

// Requires "experimentalDecorators": true and "emitDecoratorMetadata": true
import { Controller, Get, Injectable } from '@nestjs/common';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

// NestJS controller
@Controller('users')
class UserController {
  @Get()
  findAll() {
    return [];
  }
}

// TypeORM entity
@Entity()
class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  name!: string;
}

// Dependency injection: parameter decorators exist only in legacy mode
@Injectable()
class UserService {
  constructor(private readonly repo: UserRepository) {}
}

Decorator Patterns

Standard mode. Cross-cutting decorators change the lifetime and failure semantics of every method they wrap, so each one MUST state its cache scope, key derivation, and error classification explicitly.

Memoization

A memoization decorator MUST scope its cache per instance and MUST take an explicit key function. A module-level Map keyed by JSON.stringify(args) shares one entry across every instance, so a receiver-dependent method returns another object’s result; JSON.stringify also throws on BigInt and cyclic arguments and maps undefined onto null.

A memoized method MUST NOT return a single-use value such as a Response, a ReadableStream, or an iterator: the second caller receives an already consumed object.

function Memoize<This extends object, Args extends unknown[], Return>(
  keyOf: (...args: Args) => string,
) {
  return function (
    target: (this: This, ...args: Args) => Return,
    _context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
  ) {
    const caches = new WeakMap<This, Map<string, Return>>();
    return function (this: This, ...args: Args): Return {
      let cache = caches.get(this);
      if (!cache) {
        cache = new Map<string, Return>();
        caches.set(this, cache);
      }
      const key = keyOf(...args);
      if (cache.has(key)) return cache.get(key) as Return;
      const result = target.call(this, ...args);
      cache.set(key, result);
      // A rejected promise must not be cached, or the failure is permanent
      if (result instanceof Promise) {
        void result.catch(() => cache.delete(key));
      }
      return result;
    };
  };
}

Retry

A retry decorator MUST retry only classified transient failures on idempotent operations, MUST back off between attempts, and MUST honor an AbortSignal. Retrying every exception immediately turns a permanent TypeError into n synchronous failures and makes cancellation impossible.

function sleep(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }
    const onAbort = () => {
      clearTimeout(timer);
      reject(signal?.reason);
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener('abort', onAbort);
      resolve();
    }, ms);
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}

interface RetryOptions<This> {
  attempts: number;
  baseDelayMs: number;
  maxDelayMs: number;
  isTransient: (error: unknown) => boolean;
  signal?: (this: This) => AbortSignal | undefined;
}

function Retry<This, Args extends unknown[], Return>(options: RetryOptions<This>) {
  return function (
    target: (this: This, ...args: Args) => Promise<Return>,
    _context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Promise<Return>>,
  ) {
    return async function (this: This, ...args: Args): Promise<Return> {
      for (let attempt = 1; ; attempt += 1) {
        const signal = options.signal?.call(this);
        signal?.throwIfAborted();
        try {
          return await target.call(this, ...args);
        } catch (error) {
          if (attempt >= options.attempts || !options.isTransient(error)) throw error;
          const backoff = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** (attempt - 1));
          await sleep(backoff * (0.5 + Math.random() / 2), signal);
        }
      }
    };
  };
}

Composing them

Decorators apply bottom-up, so @Retry wraps the method and @Memoize caches the fully retried result. The method returns parsed data rather than the Response itself, because a Response body can only be read once.

class HttpError extends Error {
  constructor(readonly status: number) {
    super(`HTTP ${status}`);
  }
}

interface User {
  id: string;
  name: string;
}

class ApiClient {
  readonly #controller = new AbortController();

  @Memoize<ApiClient, [string], Promise<User>>((id) => id)
  @Retry<ApiClient, [string], User>({
    attempts: 3,
    baseDelayMs: 100,
    maxDelayMs: 2_000,
    isTransient: (error) => error instanceof HttpError && error.status >= 500,
    signal() {
      return this.#controller.signal;
    },
  })
  async fetchUser(id: string): Promise<User> {
    const response = await fetch(`https://api.example.com/users/${id}`, {
      signal: this.#controller.signal,
    });
    if (!response.ok) throw new HttpError(response.status);
    return (await response.json()) as User;
  }

  cancel(): void {
    this.#controller.abort();
  }
}

Required tests

These are the cases that distinguish a correct implementation from the plausible-looking one it replaces, so a project using these patterns MUST cover all of them:

import { expect, test } from 'vitest';
import { Memoize, Product, Retry } from '../patterns.js';

test('accessor decorator validates assignment, not just initialization', () => {
  const p = new Product();
  expect(p.quantity).toBe(10);
  expect(() => {
    p.quantity = 500;
  }).toThrow(RangeError);
  p.quantity = 50;
  expect(p.quantity).toBe(50);
});

class Counter {
  calls = 0;

  @Memoize<Counter, [string], string>((k) => k)
  lookup(key: string): string {
    this.calls += 1;
    return `${key}:${this.calls}`;
  }
}

test('memoisation is per instance', () => {
  const a = new Counter();
  const b = new Counter();
  expect(a.lookup('x')).toBe('x:1');
  expect(a.lookup('x')).toBe('x:1');
  expect(a.calls).toBe(1);
  expect(b.lookup('x')).toBe('x:1');
  expect(b.calls).toBe(1);
});

class Flaky {
  attempts = 0;

  @Memoize<Flaky, [], Promise<string>>(() => 'only')
  async load(): Promise<string> {
    this.attempts += 1;
    if (this.attempts === 1) throw new Error('boom');
    return 'ok';
  }
}

test('a rejected promise is evicted from the cache', async () => {
  const f = new Flaky();
  await expect(f.load()).rejects.toThrow('boom');
  await expect(f.load()).resolves.toBe('ok');
  expect(f.attempts).toBe(2);
});

class Transient extends Error {}

class Service {
  attempts = 0;
  readonly controller = new AbortController();

  @Retry<Service, [], string>({
    attempts: 5,
    baseDelayMs: 1,
    maxDelayMs: 4,
    isTransient: (error) => error instanceof Transient,
    signal() {
      return this.controller.signal;
    },
  })
  async run(): Promise<string> {
    this.attempts += 1;
    if (this.attempts < 3) throw new Transient('retry me');
    return 'done';
  }

  @Retry<Service, [], string>({
    attempts: 5,
    baseDelayMs: 1,
    maxDelayMs: 4,
    isTransient: (error) => error instanceof Transient,
  })
  async fatal(): Promise<string> {
    this.attempts += 1;
    throw new TypeError('not transient');
  }
}

test('retries only classified transient failures', async () => {
  const s = new Service();
  await expect(s.run()).resolves.toBe('done');
  expect(s.attempts).toBe(3);
});

test('a non-transient error is not retried', async () => {
  const s = new Service();
  await expect(s.fatal()).rejects.toThrow(TypeError);
  expect(s.attempts).toBe(1);
});

test('an aborted signal stops the retry loop', async () => {
  const s = new Service();
  s.controller.abort(new Error('cancelled'));
  await expect(s.run()).rejects.toThrow('cancelled');
  expect(s.attempts).toBe(0);
});

Declaration Files

Projects MUST provide type declarations for any public JavaScript APIs. Declaration files (.d.ts) describe the shape of existing JavaScript code.

Why Declaration Files

  • Type safety: Enable TypeScript to type-check usage of JavaScript libraries
  • IDE support: Provide autocomplete and documentation for JavaScript APIs
  • Interoperability: Bridge untyped JavaScript with typed TypeScript
  • Documentation: Serve as machine-readable API documentation

Generating Declarations

// tsconfig.json
{
  "compilerOptions": {
    "declaration": true,
    "declarationDir": "./types",
    "declarationMap": true,  // Enables "Go to Definition" to source
    "emitDeclarationOnly": true  // Only emit .d.ts files
  }
}

Writing Declaration Files

Ambient module declarations and global augmentations MUST NOT share a file. A declare global block is only legal when it is directly nested in an external module — a file with a top-level import or export. Combining it with a script-style declare module 'mylib' block fails with TS2669, and adding export {} to fix that would silently turn the mylib declaration into an augmentation of an existing module instead of a new one.

A script-style ambient module declaration, types/mylib.d.ts:

declare module 'mylib' {
  export function greet(name: string): string;
  export const VERSION: string;

  export interface Config {
    debug: boolean;
    timeout: number;
  }

  export class Client {
    constructor(config: Config);
    connect(): Promise<void>;
    disconnect(): void;
  }

  // Default export
  export default function init(config: Config): Client;
}

Global augmentation in its own external-module file, types/globals.d.ts. The export {} is what makes the file a module and the declare global block legal:

export {};

declare global {
  interface Window {
    myApp: {
      version: string;
      init(): void;
    };
  }

  const __DEV__: boolean;
  const __VERSION__: string;
}

Module augmentation in its own external-module file, types/express.d.ts. The import both makes the file a module and pins the augmentation to the real package:

import 'express';

declare module 'express' {
  interface Request {
    userId?: string;
    sessionId?: string;
  }
}

Package Type Declarations

package.json is strict JSON: it MUST NOT contain comments, so the filename label stays out of the file. The following is the complete contents of package.json:

{
  "name": "my-package",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  }
}

DefinitelyTyped

For third-party JavaScript libraries without types, use types from DefinitelyTyped12:

# Install types for a library
npm install --save-dev @types/lodash@4.17.26
npm install --save-dev @types/express@5.0.6
npm install --save-dev @types/node@26.6.5

# Types are automatically used by TypeScript

Triple-Slash Directives

// Reference another declaration file
/// <reference path="./other-types.d.ts" />

// Reference built-in lib types
/// <reference lib="dom" />
/// <reference lib="es2022" />

// Reference types package
/// <reference types="node" />

Declaration File Patterns

// types/utils.d.ts

// Function overloads
export function parse(input: string): object;
export function parse(input: Buffer): object;
export function parse(input: string, options: ParseOptions): object;

// Generic functions
export function identity<T>(value: T): T;
export function map<T, U>(array: T[], fn: (item: T) => U): U[];

// Utility types
export type Nullable<T> = T | null;
export type DeepReadonly<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
};

// Conditional types
export type Unwrap<T> = T extends Promise<infer U> ? U : T;
export type ElementOf<T> = T extends (infer E)[] ? E : never;

Module Resolution

Projects MUST configure module resolution to match their runtime environment and bundler requirements.

Why Module Resolution Matters

  • Correctness: Ensures TypeScript finds the same modules as the runtime
  • Compatibility: Different environments (Node.js, browsers, bundlers) have different resolution rules
  • Performance: Proper configuration reduces failed resolution attempts
  • Predictability: Eliminates “works on my machine” module resolution issues

Module Resolution Strategies

StrategyUse Casetsconfig Setting
Node16/NodeNextModern Node.js (ESM + CJS)"moduleResolution": "NodeNext"
BundlerWebpack, Vite, esbuild"moduleResolution": "Bundler"

TypeScript 7 removed moduleResolution: "node10" together with its "Node" alias, and rejects the option with TS5108. Projects targeting Node MUST use NodeNext; projects whose modules are resolved by a bundler MUST use Bundler.

tsconfig.json for Node.js 16+:

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022"
  }
}
// With NodeNext, extensions are required for relative imports
import { foo } from './foo.js';  // Note: .js extension even for .ts files
import { bar } from './utils/bar.js';

// Package imports work as expected
import express from 'express';
import { z } from 'zod';

Bundler Mode (Vite, Webpack)

tsconfig.json for bundler environments:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true
  }
}
// With Bundler, extensions are optional
import { foo } from './foo';
import { bar } from './utils/bar';

// Bundler-specific features work
import styles from './styles.module.css';
import data from './data.json';

Path Aliases

paths only affects how TypeScript resolves a specifier. tsc emits the specifier unchanged, so an alias MUST NOT be used unless something in the pipeline implements the identical mapping at run time. Emitting import { formatDate } from '@utils/date.js' and running it under Node fails with ERR_MODULE_NOT_FOUND.

TypeScript 7 also removed baseUrl (TS5102) and now requires every paths target to be relative to the config file (TS5090).

Bundler applications — declare the alias twice, once for the type checker and once for the bundler, and keep the two in step:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"],
      "@utils/*": ["./src/utils/*"]
    }
  }
}
// vite.config.ts
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
      '@utils': fileURLToPath(new URL('./src/utils', import.meta.url)),
    },
  },
});

Node applications — use package subpath imports instead. Node implements #-prefixed specifiers natively, and TypeScript resolves them through the same package.json field, so no second mapping can drift:

import { formatDate } from '#utils/date';

See Subpath Imports below for the matching package.json.

Package.json Exports

The following is the complete contents of package.json:

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.js"
    }
  },
  "typesVersions": {
    "*": {
      "utils": ["./dist/utils.d.ts"]
    }
  }
}

Subpath Imports

Import targets MUST point at the emitted files, not the sources. TypeScript maps the emitted path back to its input through outDir and rootDir, so both the compiler and Node resolve the same specifier.

A conditional target MUST end in a "default" branch. development and production are user conditions: Node only applies them when they are passed with node --conditions, and setting NODE_ENV has no effect on package resolution. Without a default, #config fails with ERR_PACKAGE_IMPORT_NOT_DEFINED.

The following is the complete contents of package.json:

{
  "name": "my-app",
  "type": "module",
  "imports": {
    "#utils/*": "./dist/utils/*.js",
    "#components/*": "./dist/components/*.js",
    "#config": {
      "development": "./dist/config/dev.js",
      "default": "./dist/config/prod.js"
    }
  }
}

Declare the same user condition for the type checker with customConditions, which requires moduleResolution: "NodeNext" or "Bundler":

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "customConditions": ["development"]
  }
}
// Use subpath imports (private to package)
import config from '#config';
import { logger } from '#utils/logger';
# Production: resolves the "default" branch
node dist/main.js

# Development: the condition must be requested explicitly
node --conditions=development dist/main.js

Module Resolution Debugging

# Trace module resolution
npx --package=typescript@6.0.3 tsc --traceResolution

# Check specific module
npx --package=typescript@6.0.3 tsc --traceResolution 2>&1 | grep "some-module"

# Explain why a file is included
npx --package=typescript@6.0.3 tsc --explainFiles

Common Resolution Issues

// Issue: "Cannot find module './foo'"
// Solution 1: Add extension for NodeNext
import { foo } from './foo.js';

// Solution 2: Check paths in tsconfig.json
// Solution 3: Verify file exists and casing matches

// Issue: "Cannot find type definitions for 'express'"
// Solution: Install @types package
// npm install --save-dev @types/express@5.0.6

// Issue: Module works at runtime but TypeScript errors
// Solution: Check moduleResolution matches your runtime

Dead Code Detection: ts-prune

Projects SHOULD regularly check for unused exports using ts-prune3.

# Install
npm install --save-dev ts-prune@0.10.3

# Find unused exports
npx ts-prune@0.10.3

Code Coverage: c8

Projects SHOULD measure code coverage using c84 or Vitest5’s built-in coverage provider.

c84 uses V8’s built-in coverage for fast, accurate results.

# With Vitest
npx c8@12.0.0 vitest run

# Threshold
npx c8@12.0.0 --check-coverage --lines 80 vitest run

Or with Vitest5’s built-in coverage:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      thresholds: {
        lines: 80,
        branches: 80,
      },
    },
  },
});

Test Performance: Vitest

Projects MUST use Vitest5 as the test runner for TypeScript projects.

Why Vitest

  • Native ESM support: Works seamlessly with modern TypeScript modules
  • Vite-powered: Leverages Vite11’s transform pipeline for instant hot module replacement
  • Jest-compatible API: Easy migration from Jest13 with familiar syntax
  • Built-in coverage: Native V8 coverage support without additional configuration
  • Fast watch mode: Near-instant test re-runs on file changes
# Install
npm install --save-dev --save-exact vitest@5.0.0

# Run
npx vitest@5.0.0

# Watch mode
npx vitest@5.0.0 --watch

# Report the environment and configuration Vitest actually resolved
npx vitest@5.0.0 doctor

Configuration

Vitest 5 removed poolOptions. Worker concurrency is now controlled by the top-level maxWorkers and fileParallelism options.

The default pool is forks, which isolates each test file in its own child process. threads is still supported and is faster for CPU-bound suites, but it shares one process, so a test that mutates global state or leaks a native handle can corrupt unrelated files. Projects MUST NOT switch pools without a measurement from vitest doctor or a timed comparison showing the gain.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    // 'forks' is the default; measure before overriding it
    pool: 'forks',
    maxWorkers: 4,
    fileParallelism: true,
  },
});

Pre-commit Configuration

The hook repository is tagged in lockstep with the Biome1 release, so rev MUST match the pinned @biomejs/biome version:

repos:
  - repo: https://github.com/biomejs/pre-commit
    rev: v2.5.12
    hooks:
      - id: biome-check

CI Pipeline

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx @biomejs/biome@2.5.12 ci .
      - run: npx --package=typescript@6.0.3 tsc --noEmit

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx vitest@5.0.0 run --coverage

Dependencies & Package Management

Projects SHOULD prefer pnpm14 for package management due to its speed and disk efficiency:

# Prefer pnpm for speed
pnpm install
pnpm add express
pnpm add -D @types/express

Lock files: Projects MUST commit lock files (package-lock.json or pnpm-lock.yaml) for reproducible builds.

Version constraints:

  • ^1.2.3 - Compatible updates (>=1.2.3 <2.0.0)
  • ~1.2.3 - Patch updates only (>=1.2.3 <1.3.0)
  • 1.2.3 - Exact version (pinned)

Vulnerability scanning: Projects MUST regularly scan for vulnerabilities:

npm audit
pnpm audit

Dependabot: Projects SHOULD enable automated dependency updates15 (.github/dependabot.yml):

version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly

E2E & Acceptance Testing

Projects SHOULD use Playwright16 for browser E2E testing and MAY use Cucumber.js17 for BDD-style acceptance testing.

Playwright16 for browser E2E:

import { test, expect } from '@playwright/test';

test('user login flow', async ({ page }) => {
  await page.goto('/login');
  await page.fill('#email', 'user@example.com');
  await page.fill('#password', 'password123');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL('/dashboard');
});

Cucumber.js17 for BDD acceptance:

Feature: User Authentication
  Scenario: Successful login
    Given I am on the login page
    When I enter valid credentials
    Then I should see the dashboard
import { Given, When, Then } from '@cucumber/cucumber';

Given('I am on the login page', async function () {
  await this.page.goto('/login');
});

Page Object Pattern: Projects SHOULD use the Page Object Pattern for maintainable E2E tests:

class LoginPage {
  constructor(private page: Page) {}

  async login(email: string, password: string) {
    await this.page.fill('#email', email);
    await this.page.fill('#password', password);
    await this.page.click('button[type="submit"]');
  }
}

Thread Safety Testing

Web Workers:

test('worker communication', async () => {
  const worker = new Worker('./worker.ts');
  const result = await new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage({ type: 'process', data: [1, 2, 3] });
  });
  expect(result).toEqual([2, 4, 6]);
});

SharedArrayBuffer and Atomics:

test('atomic operations', () => {
  const sab = new SharedArrayBuffer(4);
  const view = new Int32Array(sab);
  Atomics.store(view, 0, 42);
  expect(Atomics.load(view, 0)).toBe(42);
});

Testing async race conditions:

test('no race condition in concurrent updates', async () => {
  const store = new ConcurrentStore();
  await Promise.all([
    store.increment('counter'),
    store.increment('counter'),
    store.increment('counter'),
  ]);
  expect(store.get('counter')).toBe(3);
});

Idempotence Testing

Retry-safe API calls:

test('duplicate requests produce same result', async () => {
  const result1 = await api.createOrder({ idempotencyKey: 'key-123', items: [] });
  const result2 = await api.createOrder({ idempotencyKey: 'key-123', items: [] });
  expect(result1.id).toBe(result2.id);
});

Optimistic update testing:

test('rollback on conflict', async () => {
  const ui = renderComponent();
  ui.updateItem(1, { name: 'New Name' }); // Optimistic
  await waitFor(() => expect(ui.getItem(1).name).toBe('Original Name')); // Rolled back
});

Reliability & Resilience Testing

Node’s fetch has no origin, so a relative specifier such as /api/data rejects with TypeError: Failed to parse URL before any request is made. Tests in the default Node environment MUST use an absolute URL and MUST install a request mock, otherwise an assertion on cancellation or on a response body passes or fails for the wrong reason.

MSW server lifecycle (shared by the tests below):

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    setupFiles: ['./test/setup.ts'],
  },
});
// test/server.ts
import { setupServer } from 'msw/node';

export const server = setupServer();
// test/setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './server.js';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

AbortController:

import { delay, http, HttpResponse } from 'msw';
import { expect, test } from 'vitest';
import { server } from './server.js';

const BASE_URL = 'https://api.example.test';

test('cancelling an in-flight request rejects with AbortError', async () => {
  server.use(
    http.get(`${BASE_URL}/data`, async () => {
      await delay('infinite');
      return HttpResponse.json({});
    }),
  );
  const controller = new AbortController();
  const promise = fetch(`${BASE_URL}/data`, { signal: controller.signal });
  controller.abort();
  await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
});

Assert on name: 'AbortError' rather than on message text: the message differs between runtimes and between an explicit abort() and a timeout.

Timeout and retry:

test('retries on failure', async () => {
  let attempts = 0;
  const fn = async () => {
    attempts++;
    if (attempts < 3) throw new Error('fail');
    return 'success';
  };
  const result = await retry(fn, { maxAttempts: 3 });
  expect(result).toBe('success');
  expect(attempts).toBe(3);
});

Network error simulation: MSW18 produces a Fetch network error with HttpResponse.error(). There is no HttpResponse.networkError().

test('handles network errors', async () => {
  server.use(http.get(`${BASE_URL}/data`, () => HttpResponse.error()));
  await expect(fetch(`${BASE_URL}/data`)).rejects.toThrow(TypeError);
});

Compatibility Testing

Browser compatibility (Playwright):

import { chromium, firefox, webkit } from '@playwright/test';

for (const browserType of [chromium, firefox, webkit]) {
  test(`works in ${browserType.name()}`, async () => {
    const browser = await browserType.launch();
    const page = await browser.newPage();
    await page.goto('/');
    await expect(page.locator('h1')).toBeVisible();
    await browser.close();
  });
}

Node.js version matrix (.github/workflows/test.yml):

strategy:
  matrix:
    node-version: [18, 20, 22]
steps:
  - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
    with:
      node-version: ${{ matrix.node-version }}

tsconfig target options:

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

Internationalization Testing

UTF-8 handling (native): String.prototype.length counts UTF-16 code units, so the astral-plane 🌍 counts as two. Iteration yields code points, and neither figure matches user-perceived characters — combining marks, emoji sequences, and Hangul jamo all span several code points. Use Intl.Segmenter whenever the answer is meant to be “how many characters would a reader see.”

test('handles unicode correctly', () => {
  const text = '你好世界 🌍';
  expect(text.length).toBe(7); // UTF-16 code units: 🌍 is a surrogate pair
  expect([...text].length).toBe(6); // Code points
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  expect([...segmenter.segment(text)].length).toBe(6); // Grapheme clusters
});

i18next19 testing:

import i18n from 'i18next';

test('translates messages', async () => {
  await i18n.init({
    lng: 'es',
    resources: {
      es: { translation: { hello: 'Hola' } },
    },
  });
  expect(i18n.t('hello')).toBe('Hola');
});

RTL layout testing:

test('supports RTL layout', async ({ page }) => {
  await page.goto('/?lang=ar');
  const dir = await page.locator('html').getAttribute('dir');
  expect(dir).toBe('rtl');
});

Data Integrity Testing

Projects MUST validate runtime data using schema validation libraries like Zod20.

Zod20 schema validation:

import { z } from 'zod';

const UserSchema = z.object({
  email: z.string().email(),
  age: z.number().min(0),
});

test('validates user data', () => {
  expect(() => UserSchema.parse({ email: 'invalid', age: -1 })).toThrow();
  expect(UserSchema.parse({ email: 'user@example.com', age: 25 })).toEqual({
    email: 'user@example.com',
    age: 25,
  });
});

API contract testing: the request MUST be mocked and the URL absolute, or the test fails on URL parsing before the schema is ever exercised.

test('API response matches schema', async () => {
  server.use(
    http.get(`${BASE_URL}/users/1`, () =>
      HttpResponse.json({ email: 'user@example.com', age: 25 }),
    ),
  );
  const response = await fetch(`${BASE_URL}/users/1`);
  expect(response.ok).toBe(true);
  expect(UserSchema.parse(await response.json())).toEqual({
    email: 'user@example.com',
    age: 25,
  });
});

A/B Testing & Feature Flags

Feature flag libraries (Unleash21):

import { Unleash } from 'unleash-client';

const unleash = new Unleash({
  url: 'https://unleash.example.com/api',
  appName: 'my-app',
});

if (unleash.isEnabled('new-checkout-flow')) {
  // New implementation
}

Testing both flag states:

test.each([
  { flag: true, expected: 'New UI' },
  { flag: false, expected: 'Old UI' },
])('renders correct UI when flag is $flag', ({ flag, expected }) => {
  vi.spyOn(featureFlags, 'isEnabled').mockReturnValue(flag);
  const ui = render(<Component />);
  expect(ui.getByText(expected)).toBeInTheDocument();
});

References

See Also

  • Testing Guide - Comprehensive testing strategies and best practices
  • CI Guide - Continuous integration configuration and workflows

Footnotes

  1. Biome - Fast formatter and linter for JavaScript/TypeScript ↩ ↩2 ↩3 ↩4 ↩5

  2. TypeScript - TypeScript official website and documentation ↩ ↩2

  3. ts-prune - Find unused exports in TypeScript projects ↩ ↩2

  4. c8 - Native V8 code coverage tool ↩ ↩2 ↩3

  5. Vitest - Next generation testing framework powered by Vite ↩ ↩2 ↩3 ↩4 ↩5

  6. ESLint - Pluggable linting utility for JavaScript and TypeScript ↩ ↩2

  7. Prettier - Opinionated code formatter ↩

  8. NestJS - Progressive Node.js framework for building server-side applications ↩ ↩2

  9. TypeORM - ORM for TypeScript and JavaScript ↩ ↩2

  10. Angular - Platform for building web applications ↩ ↩2

  11. Vite - Next generation frontend tooling ↩ ↩2

  12. DefinitelyTyped - Repository for high-quality TypeScript type definitions ↩

  13. Jest - Delightful JavaScript testing framework ↩

  14. pnpm - Fast, disk space efficient package manager ↩

  15. Dependabot - Automated dependency updates ↩

  16. Playwright - End-to-end testing for modern web apps ↩ ↩2

  17. Cucumber.js - BDD testing framework for JavaScript ↩ ↩2

  18. MSW - API mocking library for browser and Node.js ↩

  19. i18next - Internationalization framework for JavaScript ↩

  20. Zod - TypeScript-first schema validation library ↩ ↩2

  21. Unleash - Open-source feature management platform ↩