← Back to Blog

GitHub Actions Workflow: ESLint + Vitest + Nark for TypeScript Projects

By Nark Team

This is a complete GitHub Actions CI workflow for TypeScript projects that runs ESLint for linting, Vitest for testing, and Nark for dependency error handling. Copy it into .github/workflows/ci.yml and it runs on every pull request.

Quick Answer: Three tools, one workflow: ESLint catches code style and language bugs, Vitest catches logic regressions, Nark catches unhandled npm package errors (the category both ESLint and tests miss). All three run on every PR via GitHub Actions.


The Complete Workflow

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  ci:
    name: TypeScript CI
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: TypeScript — type check
        run: npx tsc --noEmit

      - name: ESLint — lint
        run: npx eslint src --ext .ts,.tsx

      - name: Vitest — tests
        run: npx vitest run

      - name: nark — dependency error handling
        run: npx nark --tsconfig tsconfig.json

That's the baseline. The sections below show how to customize each step.


What Each Step Catches

TypeScript type check

- name: TypeScript — type check
  run: npx tsc --noEmit

The TypeScript compiler validates types, interfaces, function signatures, and structural compatibility. --noEmit runs the check without generating output files.

Run this before ESLint — type errors are more critical than style issues and should fail fast.

ESLint

- name: ESLint — lint
  run: npx eslint src --ext .ts,.tsx

ESLint enforces code style rules, language-level best practices, and TypeScript-specific patterns. With @typescript-eslint, it catches:

  • @typescript-eslint/no-floating-promises — async calls with no error handling
  • @typescript-eslint/no-explicit-any — type safety leaks
  • @typescript-eslint/no-unused-vars — unused variables
  • Any custom rules your team has configured

To fail on warnings (strict mode):

- name: ESLint
  run: npx eslint src --ext .ts,.tsx --max-warnings 0

Vitest

- name: Vitest — tests
  run: npx vitest run

Vitest runs your unit and integration tests. The run flag (instead of just vitest) runs once and exits, suitable for CI.

With coverage threshold:

- name: Vitest — tests with coverage
  run: npx vitest run --coverage --coverage.thresholds.lines=80

With test results uploaded to GitHub:

- name: Vitest — tests
  run: npx vitest run --reporter=junit --outputFile=test-results.xml

- name: Upload test results
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: test-results
    path: test-results.xml

Nark

- name: nark — dependency error handling
  run: npx nark --tsconfig tsconfig.json

Nark checks every call to a profiled npm package (axios, Prisma, Stripe, Redis, etc.) against a machine-readable profile of what that package can throw at runtime.

What Nark catches that ESLint misses:

ESLint sees a try-catch and considers the error handled. Nark checks whether the catch block handles the specific errors the package throws:

// ESLint: PASSES (has try-catch)
// nark: FAILS (AxiosError network error shape not handled)
try {
  const data = await axios.get('/api/data');
  return data;
} catch (error) {
  console.error(error.response.data); // crashes when error.response is undefined
}

What Nark catches that tests miss:

Tests catch what you write tests for. Nark checks the package's profile — all the error cases documented in the package's changelog, issues, and official docs.


Customization Options

Only fail on errors, not warnings

- name: nark
  run: npx nark --tsconfig tsconfig.json --min-severity error

Useful when introducing Nark into an existing codebase with accumulated warnings. Start with errors only, then incrementally fix warnings.

Run in parallel to save time

jobs:
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx tsc --noEmit

  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx eslint src --ext .ts,.tsx

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx vitest run

  nark:
    name: Nark
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx nark --tsconfig tsconfig.json

Parallel jobs reduce total CI time. GitHub Actions shows each job's status separately in the PR check.

Cache dependencies for faster runs

- name: Setup Node.js 20
  uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'     # caches ~/.npm between runs

Or with pnpm:

- uses: pnpm/action-setup@v3
  with:
    version: 9
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'pnpm'
- run: pnpm install --frozen-lockfile

Monorepo setup

Run all three tools across multiple packages:

strategy:
  matrix:
    package: [apps/api, apps/web, packages/shared]

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with: { node-version: 20, cache: 'npm' }
  - run: npm ci

  - name: ESLint
    run: npx eslint ${{ matrix.package }}/src --ext .ts,.tsx

  - name: Vitest
    working-directory: ${{ matrix.package }}
    run: npx vitest run

  - name: nark
    run: npx nark --tsconfig ${{ matrix.package }}/tsconfig.json

Setting Up Each Tool

ESLint setup for TypeScript

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

Minimal .eslintrc.json:

{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/no-explicit-any": "warn"
  }
}

Vitest setup

npm install --save-dev vitest @vitest/coverage-v8

vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
  },
});

Nark setup

No configuration required. Nark reads your tsconfig.json and scans your TypeScript source files.

# Run locally first to see what it finds
npx nark --tsconfig tsconfig.json

Optional .nark/config.yaml for team config:

# .nark/config.yaml
minSeverity: error  # only report errors, not warnings
exclude:
  - src/**/*.test.ts   # skip test files
  - src/mocks/**       # skip mock files

Full Workflow with Everything

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

env:
  NODE_VERSION: '20'

jobs:
  ci:
    name: TypeScript CI (${{ matrix.check }})
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false
      matrix:
        check: [typecheck, lint, test, nark]

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run typecheck
        if: matrix.check == 'typecheck'
        run: npx tsc --noEmit

      - name: Run ESLint
        if: matrix.check == 'lint'
        run: npx eslint src --ext .ts,.tsx --max-warnings 0

      - name: Run Vitest
        if: matrix.check == 'test'
        run: npx vitest run --coverage

      - name: Run nark
        if: matrix.check == 'nark'
        run: npx nark --tsconfig tsconfig.json

This runs all four checks in parallel. Each check shows up as a separate status check on the PR.


Frequently Asked Questions

Does Nark slow down CI?

Nark typically takes 15-30 seconds on a typical TypeScript project. It uses the TypeScript compiler for parsing, which has some startup overhead. Running it in parallel with Vitest keeps total CI time low.

Do I need to configure Nark for my project?

No. Nark reads your tsconfig.json and automatically finds all TypeScript source files. It has profiles for 160+ npm packages — including axios, prisma, stripe, and redis — and only reports on packages you actually use.

What if ESLint is already configured to catch promise rejections?

@typescript-eslint/no-floating-promises and Nark are complementary. ESLint catches bare async calls without any error handling. Nark catches calls where you have error handling but it's incorrect or incomplete for the specific package. They do not overlap.

Can I run this on a monorepo?

Yes. Use the matrix strategy to run Nark against each package's tsconfig. See the monorepo setup section above.


Try It Now

Add Nark to your existing CI pipeline with a single step:

- name: nark — dependency error handling
  run: npx nark --tsconfig tsconfig.json

Or run locally first to see what it finds:

npx nark --tsconfig ./tsconfig.json

Nark checks 160+ npm packages — including axios, prisma, stripe, and redis — for correct error handling and Nark profiles. It's the quality gate that makes your ESLint + Vitest setup complete.