Skip to content

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:

PackageUse it for
@wtf/project-typescriptThe TypeScript configs: ./base, ./strict, ./test, ./bundler
@wtf/project-oxlintThe oxlint config (./config)
@wtf/project-oxfmtThe oxfmt config (./config)
@wtf/project-resolveThe resolve hook that runs the workspace’s TypeScript from source (./register)
@wtf/project-viteThe base Vite config (./config); it also brings the workspace’s Vite
@wtf/project-vitestThe Vitest configs (./config, ./process, ./browser, ./storybook)
@wtf/project-typedocThe TypeDoc config for a docs package (./config, ./plugins)
@wtf/project-wptA standard’s Web Platform Tests, run against your implementation, in Vitest

Set up a new repo

Terminal window
pnpm add --save-dev @wtf/project-typescript @wtf/project-oxlint @wtf/project-oxfmt \
@wtf/project-resolve @wtf/project-vite @wtf/project-vitest oxlint oxfmt typescript
pnpm add --save-dev --save-exact vitest@5.0.5 @vitest/coverage-v8@5.0.5

You 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 root
import { rootConfig } from '@wtf/project-vitest/config'
export default rootConfig()
oxlint.config.ts
import preset from '@wtf/project-oxlint/config'
import { defineConfig } from 'oxlint'
export default defineConfig({ extends: [preset] })
oxfmt.config.ts
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 in src), then types and default (its build in dist). files ships dist, never src. Strip @wtf/source when you publish, as a consumer has no src to 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 .ts file, 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"]
}