Skip to content

TypeScript – Configuration

The TypeScript compiler configuration (tsconfig.json) is the foundation of every TypeScript project. A well-configured tsconfig.json maximises the compiler's ability to catch bugs before runtime and ensures consistent behaviour across all developer machines and CI.

Strict mode is mandatory

All Cygnus Dynamics TypeScript projects must have "strict": true. Disabling or weakening strict mode silently removes the compiler's most important checks. Any PR that weakens the tsconfig.json will be rejected in code review.

Standard tsconfig.json

Baseline configuration for all Cygnus Dynamics TypeScript projects:

{
  "compilerOptions": {

    // ── Type Checking ──────────────────────────────────────────────────
    "strict": true,                             // Enables all strict checks below
    "noImplicitAny": true,                      // Error on implicit 'any' types
    "strictNullChecks": true,                   // null and undefined are distinct types
    "strictFunctionTypes": true,                // Strict checking of function types
    "strictBindCallApply": true,                // Strict bind/call/apply
    "strictPropertyInitialization": true,       // Class properties must be initialised
    "noImplicitThis": true,                     // Error on 'this' with implicit 'any'
    "alwaysStrict": true,                       // Emit 'use strict' in all files
    "noUncheckedIndexedAccess": true,           // Array access returns T | undefined
    "noImplicitOverride": true,                 // Require 'override' keyword on overrides
    "noPropertyAccessFromIndexSignature": true, // Bracket notation for index signatures
    "exactOptionalPropertyTypes": true,         // Absent ≠ undefined for optional props

    // ── Code Quality ───────────────────────────────────────────────────
    "noUnusedLocals": true,                     // Error on unused local variables
    "noUnusedParameters": true,                 // Error on unused parameters
    "noFallthroughCasesInSwitch": true,         // Error on switch fallthrough
    "noImplicitReturns": true,                  // All code paths must return
    "forceConsistentCasingInFileNames": true,   // Case-sensitive import resolution

    // ── Module System ──────────────────────────────────────────────────
    "module": "ESNext",                         // ESM output
    "moduleResolution": "bundler",              // For Vite/webpack/Next.js
    // "moduleResolution": "NodeNext",          // For Node.js ESM — use this instead
    "verbatimModuleSyntax": true,               // TS 5.0+: enforces import type usage
    "esModuleInterop": true,                    // Default imports from CommonJS
    "allowSyntheticDefaultImports": true,       // import React from 'react'
    "resolveJsonModule": true,                  // Can import .json files

    // ── Output ─────────────────────────────────────────────────────────
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],   // For browser/Next.js projects
    // "lib": ["ES2022"],                       // For Node.js-only projects
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,                        // Generate .d.ts (library projects)
    "declarationMap": true,                     // Source maps for .d.ts
    "sourceMap": true,

    // ── Path Aliases ───────────────────────────────────────────────────
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },

  "include": ["src/**/*", "tests/**/*"],
  "exclude": ["node_modules", "dist", "coverage"]
}

What Each Strict Flag Does

strictNullChecks — The Most Important Flag

Without it, null and undefined are assignable to every type — the root cause of the majority of runtime TypeErrors:

// Without strictNullChecks — TypeScript allows this
function getUser(id: string): User {
  return database.findUser(id);  // returns User | null — compiler ignores it
}
const user = getUser('123');
console.log(user.email);  // 💥 RuntimeError: Cannot read properties of null

// With strictNullChecks — compiler forces null handling
function getUser(id: string): User | null {
  return database.findUser(id);
}
const user = getUser('123');
if (user) {
  console.log(user.email);   // ✅ safe
}
console.log(user?.email);    // ✅ optional chaining

noUncheckedIndexedAccess — Array and Object Safety

Array access returns T | undefined, not T. Catches the common assumption that elements exist:

const orders: Order[] = await fetchOrders();

// Without noUncheckedIndexedAccess
const first = orders[0];  // type: Order — but may be undefined!
console.log(first.id);    // 💥 crashes if array is empty

// With noUncheckedIndexedAccess
const first = orders[0];  // type: Order | undefined
if (first) {
  console.log(first.id);  // ✅ safe
}
const id = orders[0]?.id; // type: string | undefined

noImplicitOverride — Safe Inheritance

Requires the override keyword on methods that override a parent class:

class BaseService {
  protected validateInput(data: unknown): boolean { return true; }
}

class OrderService extends BaseService {
  // ✅ If BaseService.validateInput is renamed, this becomes a compile error
  override validateInput(data: unknown): boolean {
    return super.validateInput(data) && this.checkOrderRules(data);
  }
}

exactOptionalPropertyTypes — Precise Optional Props

Distinguishes a property being absent from being explicitly undefined:

interface UpdateUserRequest {
  name?: string;  // absent — caller did not include it
}

// With exactOptionalPropertyTypes
const update: UpdateUserRequest = { name: undefined };  // ❌ Error
const update: UpdateUserRequest = {};                    // ✅ Absent
const update: UpdateUserRequest = { name: 'Alice' };    // ✅ Present

verbatimModuleSyntax — Enforced import type (TS 5.0+)

verbatimModuleSyntax replaces isolatedModules in TypeScript 5.0+. It enforces that type-only imports are written with import type, which ensures they are fully erased from the output and never create runtime dependencies:

// ✅ With verbatimModuleSyntax — type imports are explicit
import type { Order, OrderStatus } from './order.types.js';
import { OrderService } from './order.service.js';

// ✅ Inline type imports are also valid
import { type Order, OrderService } from './orders.js';

// ❌ With verbatimModuleSyntax enabled, this is an error if Order is type-only
import { Order, OrderService } from './orders.js';
// Error: 'Order' is a type and must be imported using a type-only import
// when 'verbatimModuleSyntax' is enabled.

Migration from isolatedModules

If your project currently uses "isolatedModules": true, migrate to "verbatimModuleSyntax": true. They solve the same problem — ensuring each file is a standalone module for bundlers like esbuild and Vite — but verbatimModuleSyntax provides the additional guarantee that all type imports are explicitly annotated, making the code self-documenting.

Project-Specific Configurations

React / Next.js Frontend

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "jsx": "react-jsx",
    "allowJs": false,
    "esModuleInterop": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Node.js Backend (ESM)

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "verbatimModuleSyntax": true,
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitOverride": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Shared Library / Package

{
  "compilerOptions": {
    "strict": true,
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "verbatimModuleSyntax": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "composite": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Monorepo Project References

For monorepos with multiple packages, use TypeScript project references (composite: true) to enable incremental builds and cross-package type checking. Each package declares its dependencies via references:

packages/
  shared/          # shared types and utilities
  api/             # Node.js backend
  web/             # React frontend
tsconfig.json      # root — references all packages
// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "composite": true,          // required for project references
    "declaration": true,
    "declarationMap": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

// packages/api/tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "composite": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "references": [
    { "path": "../shared" }     // api depends on shared
  ],
  "include": ["src"]
}

// tsconfig.json (root) — build all packages in dependency order
{
  "files": [],
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/api" },
    { "path": "packages/web" }
  ]
}
# Build all packages in dependency order
npx tsc --build

# Type-check only (no emit)
npx tsc --build --noEmit

# Watch mode for development
npx tsc --build --watch

ESLint with TypeScript

ESLint v9 Flat Config (current standard)

ESLint v9 uses flat config by default. Use this for all new projects — the legacy .eslintrc.js format is deprecated:

// eslint.config.ts
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        project: './tsconfig.json',
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      // Prevent any — use unknown for truly unknown types
      '@typescript-eslint/no-explicit-any': 'error',

      // Require explicit return types on exported functions
      '@typescript-eslint/explicit-module-boundary-types': 'warn',

      // Floating promises must be handled
      '@typescript-eslint/no-floating-promises': 'error',

      // Misused async functions (e.g. async in Array.forEach)
      '@typescript-eslint/no-misused-promises': 'error',

      // Prefer ?? over || for defaults
      '@typescript-eslint/prefer-nullish-coalescing': 'warn',

      // Prefer ?. over && chains
      '@typescript-eslint/prefer-optional-chain': 'warn',

      // Consistent type imports — enforces import type
      '@typescript-eslint/consistent-type-imports': ['error', {
        prefer: 'type-imports',
        fixStyle: 'inline-type-imports',
      }],

      // No unused variables
      '@typescript-eslint/no-unused-vars': ['error', {
        argsIgnorePattern: '^_',
        varsIgnorePattern: '^_',
      }],

      // Require await in async functions
      '@typescript-eslint/require-await': 'error',

      // Prefer return type annotations on public API
      '@typescript-eslint/explicit-function-return-type': ['warn', {
        allowExpressions: true,
        allowTypedFunctionExpressions: true,
      }],
    },
  },
);

Install:

npm install --save-dev \
  eslint \
  typescript-eslint \
  @typescript-eslint/parser \
  @typescript-eslint/eslint-plugin

Vitest Configuration

Use Vitest as the test runner for all TypeScript projects — it has native TypeScript support with no additional setup:

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

export default defineConfig({
  test: {
    globals: true,          // describe/it/expect without imports
    environment: 'node',    // or 'jsdom' for browser/React tests
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      thresholds: {
        statements: 80,
        branches: 80,
        functions: 80,
        lines: 80,
      },
      exclude: [
        'node_modules/',
        'dist/',
        '**/*.d.ts',
        '**/*.config.ts',
        '**/index.ts',       // barrel files — no logic to test
      ],
    },
    include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

For React projects, change environment to 'jsdom' and add @testing-library/jest-dom:

// vitest.config.ts — React / Next.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});

// src/test/setup.ts
import '@testing-library/jest-dom';

CI/CD Integration

Type checking runs as a separate CI step using tsc --noEmit — this checks types without producing output files:

# .github/workflows/ci.yml
jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci

      - name: Type check
        run: npx tsc --noEmit

      - name: Lint
        run: npx eslint src --max-warnings 0

      - name: Test with coverage
        run: npx vitest run --coverage

      - name: Build
        run: npm run build
Tool Purpose Command
tsc --noEmit Type check without build output npx tsc --noEmit
typescript-eslint Lint rules that understand TS types npx eslint src
prettier Code formatting npx prettier --write src
tsx Run .ts files directly in development npx tsx src/server.ts
tsx --watch Watch mode — faster than ts-node-dev npx tsx --watch src/server.ts
tsup Zero-config TypeScript bundler for libraries npx tsup src/index.ts
vitest Test runner with native TypeScript support npx vitest run
TypeDoc Docs from TSDoc comments npx typedoc src/index.ts

Complete package.json scripts

{
  "scripts": {
    "build":          "tsc --project tsconfig.json",
    "typecheck":      "tsc --noEmit",
    "lint":           "eslint src/",
    "lint:fix":       "eslint src/ --fix",
    "format":         "prettier --write 'src/**/*.ts'",
    "format:check":   "prettier --check 'src/**/*.ts'",
    "test":           "vitest run",
    "test:watch":     "vitest",
    "test:coverage":  "vitest run --coverage",
    "dev":            "tsx --watch src/server.ts",
    "check":          "npm run typecheck && npm run lint && npm run test"
  }
}