Skip to content

Latest commit

 

History

History
938 lines (735 loc) · 25.1 KB

File metadata and controls

938 lines (735 loc) · 25.1 KB

TypeScript Project Best Practices

Opinionated, single-path conventions for TypeScript projects on this machine. Prescriptive — fork any section if your project genuinely needs otherwise. Machine-level setup (mise, pnpm install, global config) lives in MAC.md; this file is the next layer up: how to structure and run an individual project.

Contents

Stack

Purpose Tool Install
Runtime + version mgmt Node (via mise) mise use --global node@lts (see MAC.md)
Package manager pnpm mise use --global pnpm@latest
Lint ESLint flat config + typescript-eslint pnpm add -D eslint typescript-eslint @eslint/js
Format Prettier pnpm add -D prettier
Tests Vitest pnpm add -D vitest
Type check TypeScript pnpm add -D typescript
Dev runner (TS) tsx pnpm add -D tsx
Logging (services) pino pnpm add pino (+ pino-pretty dev)

ESM only ("type": "module"). Modern Node ≥ 22.


1. Project structure

Use the src/ layout. Forces tests to import the built package surface, catches packaging mistakes early, and keeps build outputs (dist/) cleanly separated from sources.

myproj/
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── eslint.config.js
├── .prettierrc
├── vitest.config.ts
├── .nvmrc
├── .gitignore
├── README.md
├── src/
│   ├── index.ts            # public entry / barrel export
│   ├── errors.ts           # Error subclasses
│   └── logger.ts           # pino setup (services only; libs/CLIs use console)
└── tests/
    └── index.test.ts

Rules:

  • tests/ is not a separate package — Vitest discovers by glob.
  • Mirror src/ structure under tests/ once you have >1 module.
  • One package per repo. Monorepos with multiple packages (pnpm workspaces) are a separate pattern not covered here.

.gitignore (minimum):

node_modules/
dist/
coverage/
.vite/
*.tsbuildinfo
.DS_Store

Commit: package.json, pnpm-lock.yaml, .nvmrc, tsconfig.json. Gitignore: node_modules/, dist/.


2. package.json

Single source of truth for metadata, scripts, and dependencies. See the package.json template in §12.

Required fields:

  • "name", "version" (start at 0.1.0).
  • "type": "module" — ESM only. Don't mix CJS unless you have a real reason.
  • "engines": { "node": ">=22" } — paired with engine-strict=true in ~/.npmrc, this becomes a hard error if a wrong Node is in use.
  • "scripts" — at minimum dev, build, start, test, typecheck, lint, lint:fix, format, format:check. See §12.
  • "main" / "types" / "exports" for libraries; "bin" for CLIs.

Dependency pinning:

  • Lower-bound ^ ranges in package.json. Don't write = pins unless a known incompat (one-line comment with reason).
  • The upper bound is pnpm-lock.yaml's job. Commit it.

Dev vs runtime:

  • pnpm add <pkg>dependencies (runtime).
  • pnpm add -D <pkg>devDependencies (build/test/lint only).
  • peerDependencies for libraries that expect their host to provide a framework (e.g., a React component lib). Pair with peerDependenciesMeta.optional for optional integrations.

3. TypeScript (tsconfig.json)

Strict, modern, ESM. Two variants: backend (Node) and frontend (Vite). See the tsconfig.json template in §12.

Compiler options that matter:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "target": "ESNext",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ESNext"],
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "sourceMap": true,
    "paths": { "@/*": ["./src/*"] },
    "baseUrl": "."
  }
}

For Vite projects: swap "module" and "moduleResolution" to "Bundler", drop "outDir"/"rootDir"/"declaration"/"sourceMap" (Vite handles them), set "noEmit": true, and add "DOM" and "DOM.Iterable" to "lib". Vite's React template is a sane starting point.

Path aliases: @/*./src/*. For Vite, also configure vite.config.ts's resolve.alias to match. For Vitest, this is automatic via the Vite config.

tsc --noEmit runs in CI. Commit lint clean: pnpm typecheck is part of the standard scripts block.


4. ESLint (flat config)

One file: eslint.config.js. Replaces .eslintrc.* and works with ES modules natively.

Daily commands:

pnpm lint             # eslint .
pnpm lint:fix         # eslint . --fix

Recommended rule sets (composed in the flat config):

  • @eslint/js recommended
  • typescript-eslint strict-type-checked + stylistic-type-checked
  • eslint-plugin-import-x recommended + typescript
  • (Frontend only) eslint-plugin-react, eslint-plugin-react-hooks, eslint-plugin-jsx-a11y

Per-file overrides for tests:

  • Relax @typescript-eslint/no-explicit-any and unsafe-* rules for test files (mocks legitimately use any shapes).

In CI: eslint . --max-warnings 0 (warnings are treated as errors).

See the full backend ESLint config in §12 and frontend variant in §14.

No eslint-config-prettier is needed — typescript-eslint recommended sets omit stylistic rules that conflict with Prettier.


5. Prettier

.prettierrc:

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

Daily commands:

pnpm format           # prettier --write .
pnpm format:check     # prettier --check .

Add a .prettierignore mirroring .gitignore plus pnpm-lock.yaml (auto-generated).

In CI: prettier --check . (fails if any file is unformatted).


6. Vitest

Directory: tests/ at project root, no special init file. Vitest discovers via glob.

Config in vitest.config.ts:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'node',          // or 'jsdom' for frontend; see §14
    globals: false,                // explicit imports are clearer
    include: ['tests/**/*.test.ts'],
    coverage: { provider: 'v8', reporter: ['text', 'html'] },
  },
})

Naming: *.test.ts (and *.test.tsx for component tests). Mirror src/ under tests/.

Parametrize via it.each:

import { it, expect } from 'vitest'
import { square } from '@/math'

it.each([
  [1, 1],
  [2, 4],
  [3, 9],
])('square(%i) === %i', (n, expected) => {
  expect(square(n)).toBe(expected)
})

Structure each test as Arrange-Act-Assert, separated by blank lines. Split if a test has more than one "Act."

Coverage: pnpm test --coverage. No coverage gating in CI until the suite is mature.


7. TypeScript usage

Strict means strict.

  • No any. Use unknown and narrow.
  • No bare as. Cast only at typed boundaries (e.g., parsing JSON), and prefer zod or another runtime validator at the seam.
  • No // @ts-ignore. Use // @ts-expect-error <reason> so the directive becomes an error if the underlying issue is later fixed.
  • All exported functions/methods get explicit return types. The compiler will infer for non-exported helpers; that's fine.
  • Use import type for type-only imports (paired with verbatimModuleSyntax):
    import type { User } from './types.js'
    import { fetchUser } from './api.js'
  • readonly arrays/properties on public-facing types. Mutate locally only when needed.
  • Use satisfies to validate a literal against a type without widening:
    const config = { host: 'localhost', port: 5432 } satisfies DbConfig

Modern syntax only. No namespace, no enum (use as const objects + literal unions). No Function type, no Object type.


8. Logging

Split by project type:

Libraries / frontend / CLIs: plain console.*. Don't ship a logger your consumer has to configure. CLIs print to stdout for users; that's not a logger.

Node services: pino. Configured once at the entry point. See the src/logger.ts template in §12.

// src/logger.ts (services only)
import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  transport: process.env.NODE_ENV === 'production'
    ? undefined
    : { target: 'pino-pretty' },
})
// in a module that uses it
import { logger } from './logger.js'
const log = logger.child({ module: 'users' })

log.info({ userId }, 'fetched user')
log.error({ err }, 'fetch failed')

Never log: secrets, tokens, full request/response bodies, PII. Log identifiers (user_id, request_id), not contents.

Don't console.log in checked-in code outside CLIs. Prefer logger.debug / logger.info and let log level control verbosity.


9. Error handling

Define a project Error hierarchy in src/errors.ts:

export class MyProjError extends Error {
  override name = 'MyProjError'
}

export class ConfigError extends MyProjError {
  override name = 'ConfigError'
}

export class BackendTimeout extends MyProjError {
  override name = 'BackendTimeout'
}

override name = '...' (paired with noImplicitOverride: true) makes the class name show up correctly in stack traces and at instanceof checks.

Use Error.cause to chain (built-in since Node 16):

try {
  await fetch(url, { signal: AbortSignal.timeout(5000) })
} catch (e) {
  throw new BackendTimeout(`users endpoint timed out: ${url}`, { cause: e })
}

Downstream code catches MyProjError subclasses. The { cause: e } preserves the original for debugging.

Catch at boundaries, not in the middle. Translate third-party exceptions where your code meets the external lib.

Never swallow silently. If you mean to ignore, leave a one-line comment:

try {
  await rmrf(tmpdir)
} catch (e) {
  if (!(e instanceof Error) || !('code' in e) || e.code !== 'ENOENT') throw e
  // already cleaned up by caller; safe to ignore
}

Never bare catch — TypeScript types the caught value as unknown, narrow before use.


10. Dependency management

Add deps:

pnpm add zod                    # runtime
pnpm add -D vitest eslint       # dev

Optional integrations (peer dep pattern for libraries):

{
  "peerDependencies": { "redis": ">=5" },
  "peerDependenciesMeta": { "redis": { "optional": true } }
}

Upgrade:

pnpm update <pkg> --latest      # one dep
pnpm update --latest            # everything
pnpm install                    # apply

Commit lockfile changes in a single-purpose commit: deps: upgrade zod to 3.24.

Inspect: pnpm list, pnpm why <pkg>.

One-off scripts (no project):

pnpm dlx tsx -e 'console.log(1+1)'
pnpm dlx <pkg> <args>

pnpm dlx parallels npx but uses pnpm's content-addressed store.


11. Upgrading TypeScript

TypeScript is a dev dependency (§2), pinned by pnpm-lock.yaml. Treat upgrades as small, single-purpose changes; TS majors and minors can both surface new type errors, so keep them out of feature commits.

Bump it:

pnpm add -D typescript@latest
pnpm exec tsc --version

pnpm add -D <pkg>@latest rewrites the version in package.json (overriding the existing semver range) and updates the lockfile in one step. pnpm update typescript --latest is equivalent for an already-installed dep. Plain pnpm update typescript only moves within the current ^ range and won't cross a major; always pass --latest (or use @latest) when intentionally upgrading.

Workspaces / monorepo:

pnpm -r update typescript --latest

Verify locally before committing:

pnpm typecheck            # most TS-upgrade breakage shows up here
pnpm lint
pnpm test

Companion packages to check in the same commit:

  • typescript-eslint: bump if the new TS is past its supported version window. Check its release notes alongside TypeScript's.
  • @types/node: bump if also moving Node.

Common breakage:

  • Tightened strictness defaults in major releases. Read the release notes first: https://devblogs.microsoft.com/typescript/.
  • Third-party type packages lagging behind new TS internals.
  • Stale build cache after a major. Clear it: rm -rf dist *.tsbuildinfo && pnpm typecheck.

Commit shape:

deps: upgrade typescript to <new-version>

Include package.json and pnpm-lock.yaml together. If typescript-eslint moved with it, bundle them in the same commit; otherwise keep dep upgrades separate.


12. Templates

Copy-paste-ready. Rename myproj to your project name throughout.

package.json (backend / library / CLI starter)

{
  "name": "myproj",
  "version": "0.1.0",
  "description": "One line.",
  "type": "module",
  "engines": { "node": ">=22" },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit",
    "lint": "eslint . --max-warnings 0",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check ."
  },
  "dependencies": {},
  "devDependencies": {
    "@eslint/js": "^9",
    "@types/node": "^22",
    "eslint": "^9",
    "prettier": "^3",
    "tsx": "^4",
    "typescript": "^5",
    "typescript-eslint": "^8",
    "vitest": "^2"
  }
}

tsconfig.json (backend)

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "target": "ESNext",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ESNext"],
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src/**/*", "tests/**/*"],
  "exclude": ["node_modules", "dist"]
}

eslint.config.js (backend flat config)

import js from '@eslint/js'
import tseslint from 'typescript-eslint'

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
  {
    files: ['tests/**/*.ts'],
    rules: {
      '@typescript-eslint/no-explicit-any': 'off',
      '@typescript-eslint/no-unsafe-assignment': 'off',
      '@typescript-eslint/no-unsafe-member-access': 'off',
      '@typescript-eslint/no-unsafe-call': 'off',
    },
  },
  { ignores: ['dist/', 'coverage/', 'node_modules/'] },
)

src/logger.ts (services only)

import pino from 'pino'

const isDev = process.env.NODE_ENV !== 'production'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? (isDev ? 'debug' : 'info'),
  transport: isDev ? { target: 'pino-pretty' } : undefined,
  redact: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
})

Install: pnpm add pino and pnpm add -D pino-pretty.

vitest.config.ts (backend)

import { defineConfig } from 'vitest/config'
import path from 'node:path'

export default defineConfig({
  resolve: {
    alias: { '@': path.resolve(import.meta.dirname, 'src') },
  },
  test: {
    environment: 'node',
    globals: false,
    include: ['tests/**/*.test.ts'],
    coverage: { provider: 'v8', reporter: ['text', 'html'] },
  },
})

.gitignore

node_modules/
dist/
coverage/
.vite/
*.tsbuildinfo
.DS_Store
.env
.env.local

.github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck
      - run: pnpm lint
      - run: pnpm format:check
      - run: pnpm test

Pin pnpm/action-setup and actions/setup-node to current major tags; bump intentionally.


13. Quick reference

Command Purpose
pnpm init New project (writes package.json)
pnpm add <pkg> Add runtime dep
pnpm add -D <pkg> Add dev dep
pnpm remove <pkg> Remove dep
pnpm install Install from lockfile (dev)
pnpm install --frozen-lockfile Same, fail if lockfile is stale (CI)
pnpm update --latest Bump all to latest
pnpm update <pkg> --latest Bump one
pnpm run <script> Run a package.json script
pnpm exec <cmd> Run a binary from node_modules/.bin/
pnpm dlx <pkg> One-off run (no install)
pnpm test Vitest
pnpm typecheck tsc --noEmit
pnpm lint / pnpm lint:fix ESLint
pnpm format / pnpm format:check Prettier
pnpm list Show dep tree
pnpm why <pkg> Why a dep is installed

See MAC.md for one-time machine setup.


14. Frontend chapter (Vite + React + Tailwind + shadcn/ui)

The above sections apply to frontend projects too. This chapter adds the framework-specific bits.

Bootstrap

pnpm create vite@latest myapp -- --template react-ts
cd myapp
pnpm install
echo 22 > .nvmrc

This produces a working React + TS + Vite project with pnpm dev, pnpm build, pnpm preview already wired up.

Add Tailwind v4

Tailwind v4 ships a native Vite plugin — no PostCSS, no tailwind.config.js required for the basics.

pnpm add -D tailwindcss @tailwindcss/vite

Add the plugin in vite.config.ts:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
})

Add to src/index.css (replace the file's existing contents):

@import "tailwindcss";

Add shadcn/ui

pnpm dlx shadcn@latest init
pnpm dlx shadcn@latest add button dialog

Components land in src/components/ui/ — you own the source, no runtime lib.

shadcn's init writes components.json and lib/utils.ts. Commit both.

Path aliases

shadcn expects @/components, @/lib, etc. Wire the alias in three places:

tsconfig.json (compilerOptions):

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

tsconfig.node.json should mirror the bundler resolution.

vite.config.ts:

import path from 'node:path'

export default defineConfig({
  // ...
  resolve: {
    alias: { '@': path.resolve(import.meta.dirname, './src') },
  },
})

Frontend project structure

myapp/
├── src/
│   ├── main.tsx              # entry
│   ├── App.tsx
│   ├── index.css             # @import "tailwindcss";
│   ├── components/           # your components
│   │   └── ui/               # shadcn primitives (you own these)
│   ├── lib/
│   │   └── utils.ts          # cn() etc.
│   ├── hooks/
│   ├── routes/ or pages/     # routing (see 13.6)
│   └── types/
└── tests/

Routing

react-router v7. Add when needed:

pnpm add react-router

Minimal setup in src/main.tsx:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router'
import App from './App.tsx'
import About from './routes/About.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  </StrictMode>,
)

Server state (when needed)

pnpm add @tanstack/react-query
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'

const qc = new QueryClient()

function App() {
  return (
    <QueryClientProvider client={qc}>
      <Page />
    </QueryClientProvider>
  )
}

function Page() {
  const { data, isPending, error } = useQuery({
    queryKey: ['user', 1],
    queryFn: () => fetch('/api/users/1').then((r) => r.json()),
  })
  if (isPending) return <p>loading…</p>
  if (error) return <p>error: {error.message}</p>
  return <pre>{JSON.stringify(data, null, 2)}</pre>
}

Forms (when needed)

pnpm add react-hook-form zod @hookform/resolvers
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({ email: z.string().email() })
type FormValues = z.infer<typeof schema>

export function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: zodResolver(schema),
  })
  return (
    <form onSubmit={handleSubmit((v) => console.log(v))}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}
      <button type="submit">Submit</button>
    </form>
  )
}

Component testing

pnpm add -D jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

vitest.config.ts (frontend):

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'node:path'

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { '@': path.resolve(import.meta.dirname, './src') } },
  test: {
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    include: ['tests/**/*.test.{ts,tsx}'],
    globals: false,
  },
})

tests/setup.ts:

import '@testing-library/jest-dom/vitest'

Example test (tests/Counter.test.tsx):

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, it } from 'vitest'
import { Counter } from '@/components/Counter'

it('increments when the button is clicked', async () => {
  const user = userEvent.setup()
  render(<Counter />)

  await user.click(screen.getByRole('button', { name: /increment/i }))

  expect(screen.getByText(/count: 1/i)).toBeInTheDocument()
})

Test behavior, not implementation. Prefer getByRole and userEvent over fireEvent and class-name queries.

E2E (when needed)

pnpm add -D @playwright/test
pnpm exec playwright install

Add to package.json:

{
  "scripts": {
    "test:e2e": "playwright test"
  }
}

Only add E2E when the project has flows worth testing end-to-end (signup, checkout, multi-page wizards). Unit/component tests catch most bugs at a fraction of the maintenance cost.

Frontend ESLint additions

Add to the eslint.config.js flat config (alongside the backend config in §12):

import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import jsxA11y from 'eslint-plugin-jsx-a11y'

// ... in the config array:
{
  files: ['src/**/*.{ts,tsx}'],
  plugins: { react, 'react-hooks': reactHooks, 'jsx-a11y': jsxA11y },
  rules: {
    ...react.configs.recommended.rules,
    ...react.configs['jsx-runtime'].rules,
    ...reactHooks.configs.recommended.rules,
    ...jsxA11y.configs.recommended.rules,
    'react/prop-types': 'off',
  },
  settings: { react: { version: 'detect' } },
}

Install:

pnpm add -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y