Preventing type errors in TypeScript test files with tsc -p --noEmit

In TypeScript projects it’s common to have separate tsconfig files covering the entire project and separate build contexts which exclude test files.

This can lead to failing to check test files with tsc, so type errors in test files creep in unnoticed only to be discovered later when the root problem is less clear.

For example the main tsconfig.json file in a project might be similar to this:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "types": ["node"],
    "strict": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*", "test/**/*", "typings/**/*.d.ts"]
}

And then there might be a tsconfig.build.json file that extends it but excludes test files from the build:

{
  "extends": "./tsconfig.json",
  "include": ["src/**/*", "typings/**/*.d.ts"],
  "exclude": ["test", "**/*.test.ts", "**/*.spec.ts"]
}

In package.json you might then have a build script:

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json"
  }
}

And you might be running the build as a check in GitHub Actions against each PR:

---
name: PR
on:
  pull_request:
    branches:
      - main
jobs:
  build:
    name: Build
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v6
        with:
          node-version-file: .nvmrc
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
  lint:
    ...
  test:
    ...

This can end up giving a false sense of security. All PRs pass the checks, which apply the tsc build, linters and tests to all changes. However, because the build script is specifying -p tsconfig.build.json, it excludes test files from the tsc build. Test files are being executed and linted, but not passed through the tsc compiler.

ESLint won’t necessarily pick up a change that introduces a type error in a test file. A change which introduces a type error in a test file might not change that test file itself. It might change types elsewhere in the project, causing a type error in the test file which is silent due to test files being excluded from the tsc build.

Type errors in test files can creep into the project and we’ll only discover them later when we happen to look at those test files. By that time, the cause of the type errors might be more difficult to diagnose.

We can avoid that with a specific check build that covers all files by specifying the main tsconfig instead of the build-specific tsconfig:

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "build:check": "tsc -p tsconfig.json --noEmit"
  }
}

That will check all TS files included by the main tsconfig, which will pick up type errors in test files.

We can then use that in the GitHub Action to check PRs:

build:
    name: Build
    steps:
      ...
      - run: pnpm build:check

That combined with the linters and tests in the GitHub Action ensures we catch as many mistakes as possible before merging each PR, including type errors in test files.

It’s also helpful to have a check script in package.json:

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "build:check": "tsc -p tsconfig.json --noEmit",
    "lint": "eslint '{src,test}/**/*.ts' && prettier --check '{src,test}/**/*.{ts,json,md,css,html,yml,yaml}'",
    "test:coverage": "vitest run --coverage",
    "check": "pnpm lint && pnpm build:check && pnpm test:coverage"
  }
}

Then running pnpm check covers all checks in one go.

We can optionally add that as a Git pre-commit hook at .githooks/pre-commit:

#!/usr/bin/env bash
pnpm run check

Then enable it:

chmod +x .githooks/pre-commit
git config core.hooksPath .githooks

This prevents committing changes which introduce type errors in test files (as well as any other mistakes that the tsc compiler, linters and tests can catch).

Failing to run tsc against test files caused a bit of trouble for the @kensio/smartass package.

In many projects, type errors in test files are not a major problem if the tests still pass and the linters do not complain.

For the @kensio/smartass package, though, type narrowing and type assertions are what the package is all about. The correctness of compile-time types is at least as important as the runtime test assertions.

Before setting up these compiler checks for test files, an earlier change inadvertently brought in type errors in test files. The change had to be reverted later and redone. Now that the package applies tsc compiler checks to test files, that situation cannot reoccur.


View post: Preventing type errors in TypeScript test files with tsc -p --noEmit