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.
- Stack
- 1. Project structure
- 2.
package.json - 3. TypeScript (
tsconfig.json) - 4. ESLint (flat config)
- 5. Prettier
- 6. Vitest
- 7. TypeScript usage
- 8. Logging
- 9. Error handling
- 10. Dependency management
- 11. Upgrading TypeScript
- 12. Templates
- 13. Quick reference
- 14. Frontend chapter (Vite + React + Tailwind + shadcn/ui)
| 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.
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 undertests/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/.
Single source of truth for metadata, scripts, and dependencies. See the package.json template in §12.
Required fields:
"name","version"(start at0.1.0)."type": "module"— ESM only. Don't mix CJS unless you have a real reason."engines": { "node": ">=22" }— paired withengine-strict=truein~/.npmrc, this becomes a hard error if a wrong Node is in use."scripts"— at minimumdev,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 inpackage.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).peerDependenciesfor libraries that expect their host to provide a framework (e.g., a React component lib). Pair withpeerDependenciesMeta.optionalfor optional integrations.
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.
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/jsrecommendedtypescript-eslintstrict-type-checked + stylistic-type-checkedeslint-plugin-import-xrecommended + typescript- (Frontend only)
eslint-plugin-react,eslint-plugin-react-hooks,eslint-plugin-jsx-a11y
Per-file overrides for tests:
- Relax
@typescript-eslint/no-explicit-anyand unsafe-* rules for test files (mocks legitimately useanyshapes).
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.
.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).
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.
Strict means strict.
- No
any. Useunknownand narrow. - No bare
as. Cast only at typed boundaries (e.g., parsing JSON), and preferzodor 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 typefor type-only imports (paired withverbatimModuleSyntax):import type { User } from './types.js' import { fetchUser } from './api.js'
readonlyarrays/properties on public-facing types. Mutate locally only when needed.- Use
satisfiesto 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.
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.
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.
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.
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.
Copy-paste-ready. Rename myproj to your project name throughout.
{
"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"
}
}{
"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"]
}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/'] },
)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.
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'] },
},
})node_modules/
dist/
coverage/
.vite/
*.tsbuildinfo
.DS_Store
.env
.env.local
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 testPin pnpm/action-setup and actions/setup-node to current major tags; bump intentionally.
| 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.
The above sections apply to frontend projects too. This chapter adds the framework-specific bits.
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.
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";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.
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') },
},
})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/
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>,
)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>
}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>
)
}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.
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.
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