Getting started
By the end of this guide you will have a pnpm workspace of TypeScript packages that compiles, lints, formats and tests with the shared configs, runs its TypeScript straight from source, and documents its packages with TypeDoc.
Each concern is a package of its own, so you install the ones you use:
| Package | Use it for |
|---|---|
@wtf/project-typescript | The TypeScript configs: ./base, ./strict, ./test, ./bundler |
@wtf/project-oxlint | The oxlint config (./config) |
@wtf/project-oxfmt | The oxfmt config (./config) |
@wtf/project-resolve | The resolve hook that runs the workspace’s TypeScript from source (./register) |
@wtf/project-vite | The base Vite config (./config); it also brings the workspace’s Vite |
@wtf/project-vitest | The Vitest configs (./config, ./process, ./browser, ./storybook) |
@wtf/project-typedoc | The TypeDoc config for a docs package (./config, ./plugins) |
@wtf/project-wpt | A standard’s Web Platform Tests, run against your implementation, in Vitest |
Set up a new repo
pnpm add --save-dev @wtf/project-typescript @wtf/project-oxlint @wtf/project-oxfmt \ @wtf/project-resolve @wtf/project-vite @wtf/project-vitest oxlint oxfmt typescriptpnpm add --save-dev --save-exact vitest@5.0.5 @vitest/coverage-v8@5.0.5You get Vite through the presets: @wtf/project-vite depends on it, and @wtf/project-vitest
depends on @wtf/project-vite. Vitest is a peer of @wtf/project-vitest, at 5.0.5. The root
names it by that exact version, and does the same for @vitest/coverage-v8 and, if you have
browser tests, @vitest/browser-playwright. That way the Vitest that runs is the repo’s own. Name
vite only where your own code imports it, such as a vite.config, and then by
@wtf/project-vite’s range, ^8.3.5. Never name rolldown. The
Vite guide has the rule.
The root’s package.json runs everything from the root:
{ "name": "x-monorepo", "private": true, "type": "module", "scripts": { "build": "tsc -b tsconfig.json", "typecheck": "tsc -b tsconfig.test.json", "lint": "oxlint --deny-warnings", "format:check": "oxfmt --check", "test": "NODE_OPTIONS=\"$NODE_OPTIONS --import=@wtf/project-resolve/register\" vitest run --configLoader native", "test:dist": "TEST_FROM=dist vitest run --configLoader native" }, "engines": { "node": ">=24.15.0" }}You run test from the source, with nothing built; run test:dist on the build, as CI does. The
testing guide says why, and gives the pair for browser and integration tests.
Each package’s tsconfig.json extends the strict config, and its tsconfig.test.json adds the test
config on top. Each package’s vitest.config.ts is packageConfig(); the root’s is rootConfig():
{ "extends": "@wtf/project-typescript/strict", "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist", "types": ["node"] }, "include": ["src"]}// vitest.config.ts, at the rootimport { rootConfig } from '@wtf/project-vitest/config'
export default rootConfig()import preset from '@wtf/project-oxlint/config'import { defineConfig } from 'oxlint'
export default defineConfig({ extends: [preset] })import preset from '@wtf/project-oxfmt/config'import { defineConfig } from 'oxfmt'
export default defineConfig({ ...preset })Each topic has a guide of its own: the presets, the tests, where they run (testing), and Vite.
Write a package the presets fit
- Each code entry point has three conditions:
@wtf/source(its TypeScript insrc), thentypesanddefault(its build indist).filesshipsdist, neversrc. Strip@wtf/sourcewhen you publish, as a consumer has nosrcto resolve it to. - Its root,
., exports only its own types. Code lives at subpaths, one per concern, and its errors at./errors. - Imports name the
.tsfile, as./x.ts: the build rewrites them to.js(rewriteRelativeImportExtensions), and Node runs the source as it is, stripping the types.
{ "name": "@scope/lib", "type": "module", "exports": { ".": { "@wtf/source": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" } }, "files": ["dist"]}