TypeScript package style (minpeter)
An opinionated, locked toolchain for TypeScript libraries and monorepos.
"Locked" means: don't re-litigate these per project — they are the defaults.
Every choice below was distilled from minpeter's own repos and then checked
against current ecosystem best practice (see the verdicts in references/).
The locked stack
| Concern |
Choice (no substitutes) |
| Package manager |
pnpm latest, with the resolved version recorded in packageManager |
| Monorepo |
pnpm workspaces + Turborepo latest |
| Lint + format |
Biome latest via ultracite latest — zero ignores (§3) |
| Type-check |
tsc --noEmit only — never emit JS with tsc |
| Build |
tsdown latest — not tsup, not raw tsc |
| Internal deps |
namespaced source-condition @minpeter/source (§5) |
| Tests |
Vitest latest + v8 coverage |
| Versioning |
Changesets |
| Publish |
npm OIDC trusted publishing, mandatory (§6) |
| TS compiler |
TypeScript latest stable |
| Filenames |
kebab-case (enforced by ultracite) |
If you find ESLint, Prettier, tsup, npm/yarn lockfiles, a long-lived
NPM_TOKEN, or // biome-ignore comments → that's not this style. Flag it.
1. Decision: single package vs monorepo
One publishable thing → SINGLE-PACKAGE repo (src/ at root). No turbo, no source-condition.
Several related pkgs → MONOREPO (packages/*, turbo, source-condition for internal deps).
2. Scaffold checklist
pnpm-workspace.yaml with packages: ["packages/*"] (omit for single pkg);
put pnpm settings (onlyBuiltDependencies, verifyDepsBeforeRun) here, not .npmrc.
- Resolve the toolchain at scaffold time:
corepack use pnpm@latest, install
all dev tools with @latest, then keep the resolved packageManager,
dependency ranges, and lockfile that pnpm writes.
tsconfig.base.json (shared strict options) + root tsconfig.json
(noEmit, customConditions) + tsconfig.build.json (NO custom condition).
biome.jsonc = just extends: ["ultracite/biome/core", "ultracite/biome/vitest"]. Nothing else (§3).
- tsdown per package;
tsc --noEmit for typecheck.
- Per-package
package.json: type: module, sideEffects: false,
exports with the source-condition (§5), publishConfig.provenance: true.
.changeset/config.json, turbo.json, vitest.config.ts.
- CI (
ci.yml: lint → typecheck → test → build → attw) and
release.yml (Changesets + OIDC, §6).
→ All file contents are in references/templates.md. Copy them verbatim.
3. Biome via ultracite — ZERO ignores (hard rule)
The whole biome.jsonc is two lines of intent:
{
"$schema": "https://biomejs.dev/schemas/<installed-biome-version>/schema.json",
"extends": ["ultracite/biome/core", "ultracite/biome/vitest"]
}
No files.includes ignores. No overrides. No rule "off". No
// biome-ignore / // biome-ignore-all comments. ultracite is already very
strict and already relaxes the right things itself (tests, *.config.ts,
*.d.ts, generated/build dirs, noConsole off). Adding exceptions is how a
style guide rots — the rule here is fix the code, not the config.
Practical consequences (this is why zero-ignore works cleanly):
- No barrel files. ultracite's
noBarrelFile fires on re-export-only files.
Instead of overriding it (what the older repos did), expose subpath exports
that point at real modules (§5). This is also better for tree-shaking — the
rule is steering you correctly.
- kebab-case filenames,
interface over type, no enum (const
objects / unions), no any, no !, node: import protocol,
type-only import type/export type — all enforced; just write to them.
- Worktree/scratch dirs (
.omx, .omo) → put them in .gitignore; ultracite
sets vcs.useIgnoreFile: true, so Biome skips them without a Biome ignore.
See references/templates.md for the full list of what ultracite enforces.
4. Build with tsdown + tsc --noEmit
- tsdown (Rolldown/Oxc based — the modern successor to tsup) is the only
build tool.
dts: true, sourcemap: true, unbundle: true for
file-per-module output (pairs well with subpath exports).
tsc never emits. "typecheck": "tsc --noEmit". The bundler owns output.
- Default to ESM-only (
"type": "module", exports use import). Ship CJS
only if you actually have CJS consumers — and then nest types correctly per
branch (.d.ts vs .d.cts); see the dual template.
5. Internal deps: the namespaced source-condition (monorepo only)
Verdict: keep it. It's the Colin McDonnell / TypeScript-endorsed way to get
zero-build "live types" across a monorepo, and unlike publishConfig.exports it's
package-manager-agnostic. But it has sharp edges — wire all four of these or it
silently resolves to stale dist:
// each publishable package's package.json — condition FIRST, then types, then import
"exports": {
"./package.json": "./package.json",
".": {
"@minpeter/source": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
- Root
tsconfig.json: "customConditions": ["@minpeter/source"] (editor + tsc --noEmit resolve to source).
- Build config (
tsconfig.build.json, used by tsdown): must NOT carry the condition, or your .d.ts resolves sibling deps to .ts.
- tsx:
tsx --conditions @minpeter/source (tsx does not read tsconfig — it'll silently miss otherwise).
- Vitest:
resolve: { conditions: ["@minpeter/source"] }.
Guardrails (non-negotiable):
- Namespace the condition (
@minpeter/source, never bare source) so it can't collide.
- TypeScript does not error if the
src file is missing — it silently falls
back. So run attw --pack (@arethetypeswrong/cli) in CI on every
publishable package to prove external consumers get correct dist types.
Full rationale, the contested history, every footgun, and citations:
references/source-condition.md. (Single-package repos: skip all of this.)
6. Publish: npm OIDC trusted publishing (mandatory)
No NPM_TOKEN. Ever. Releases authenticate via GitHub OIDC; provenance is
attached automatically.
- Release job needs
permissions: { id-token: write, contents: read },
the Node runtime currently recommended by npm's trusted-publishing docs, and
npm@latest installed immediately before publishing.
- Repo-level gate: Settings → Actions → General → Workflow permissions →
enable "Allow GitHub Actions to create and approve pull requests". New repos
default to read-only with PR creation off; without this, the CI run that opens
the Version Packages PR fails with 403 even when the workflow declares
pull-requests: write.
changeset publish works under OIDC; do not set NODE_AUTH_TOKEN on the
publish step (npm only uses OIDC when the auth token env is unset).
publishConfig: { "access": "public", "provenance": true } per package.
- One-time setup: configure the package's Trusted Publisher on npmjs.com
(org/user + repo + workflow filename). Caveat: OIDC can't publish a
package's first ever version — do the initial publish with a token, then
switch to OIDC.
Step-by-step + the release workflow: references/publishing-oidc.md.
7. Review checklist (use when auditing an existing repo)
NOT this style (flag in review)
ESLint / Prettier · tsup or raw-tsc library builds · npm/yarn/bun lockfiles ·
NPM_TOKEN secret for publishing · // biome-ignore or biome overrides ·
barrel index.ts re-export files · default-export-heavy modules ·
PascalCase/snake_case filenames · enum · any · non-null !.
1---2name: typescript-package3description: minpeter's house style for building and reviewing TypeScript packages and monorepos. The locked stack: pnpm (latest) + Turborepo + Changesets + Biome via the ultracite preset with ZERO ignores/overrides/suppressions + tsdown for builds + Vitest, with `tsc --noEmit` for type-checking, the namespaced source-condition for zero-build internal deps, and MANDATORY npm OIDC trusted publishing (no NPM_TOKEN). Use this when scaffolding a new TS library or monorepo, adding a workspace package, choosing build/lint/publish tooling, or reviewing an existing package.json / tsconfig / biome.jsonc / turbo.json / CI for consistency with this style. Copy-paste config lives in references/templates.md; the source-condition deep-dive + external verdict in references/source-condition.md; the OIDC publishing guide in references/publishing-oidc.md.4license: MIT5---67# TypeScript package style (minpeter)89An opinionated, **locked** toolchain for TypeScript libraries and monorepos.10"Locked" means: don't re-litigate these per project — they are the defaults.11Every choice below was distilled from minpeter's own repos and then checked12against current ecosystem best practice (see the verdicts in `references/`).1314## The locked stack1516| Concern | Choice (no substitutes) |17|----------------|----------------------------------------------------------|18| Package manager| **pnpm latest**, with the resolved version recorded in `packageManager` |19| Monorepo | pnpm workspaces + **Turborepo latest** |20| Lint + format | **Biome latest** via **`ultracite` latest** — **zero ignores** (§3) |21| Type-check | **`tsc --noEmit`** only — never emit JS with tsc |22| Build | **tsdown latest** — **not tsup**, not raw tsc |23| Internal deps | **namespaced source-condition** `@minpeter/source` (§5) |24| Tests | **Vitest latest** + v8 coverage |25| Versioning | **Changesets** |26| Publish | **npm OIDC trusted publishing**, mandatory (§6) |27| TS compiler | **TypeScript latest stable** |28| Filenames | **kebab-case** (enforced by ultracite) |2930> If you find ESLint, Prettier, tsup, npm/yarn lockfiles, a long-lived31> `NPM_TOKEN`, or `// biome-ignore` comments → that's **not** this style. Flag it.3233## 1. Decision: single package vs monorepo3435```36One publishable thing → SINGLE-PACKAGE repo (src/ at root). No turbo, no source-condition.37Several related pkgs → MONOREPO (packages/*, turbo, source-condition for internal deps).38```3940## 2. Scaffold checklist41421. `pnpm-workspace.yaml` with `packages: ["packages/*"]` (omit for single pkg);43 put pnpm settings (`onlyBuiltDependencies`, `verifyDepsBeforeRun`) here, not `.npmrc`.442. Resolve the toolchain at scaffold time: `corepack use pnpm@latest`, install45 all dev tools with `@latest`, then keep the resolved `packageManager`,46 dependency ranges, and lockfile that pnpm writes.473. `tsconfig.base.json` (shared strict options) + root `tsconfig.json`48 (`noEmit`, `customConditions`) + `tsconfig.build.json` (NO custom condition).494. `biome.jsonc` = **just** `extends: ["ultracite/biome/core", "ultracite/biome/vitest"]`. Nothing else (§3).505. **tsdown** per package; `tsc --noEmit` for typecheck.516. Per-package `package.json`: `type: module`, `sideEffects: false`,52 `exports` with the source-condition (§5), `publishConfig.provenance: true`.537. `.changeset/config.json`, `turbo.json`, `vitest.config.ts`.548. CI (`ci.yml`: lint → typecheck → test → build → **attw**) and55 `release.yml` (Changesets + **OIDC**, §6).5657→ **All file contents are in [`references/templates.md`](references/templates.md).** Copy them verbatim.5859## 3. Biome via ultracite — ZERO ignores (hard rule)6061The whole `biome.jsonc` is two lines of intent:6263```jsonc64{65 "$schema": "https://biomejs.dev/schemas/<installed-biome-version>/schema.json",66 "extends": ["ultracite/biome/core", "ultracite/biome/vitest"]67}68```6970**No `files.includes` ignores. No `overrides`. No rule `"off"`. No71`// biome-ignore` / `// biome-ignore-all` comments.** ultracite is already very72strict and already relaxes the right things itself (tests, `*.config.ts`,73`*.d.ts`, generated/build dirs, `noConsole` off). Adding exceptions is how a74style guide rots — the rule here is **fix the code, not the config**.7576Practical consequences (this is *why* zero-ignore works cleanly):77- **No barrel files.** ultracite's `noBarrelFile` fires on re-export-only files.78 Instead of overriding it (what the older repos did), **expose subpath exports79 that point at real modules** (§5). This is also better for tree-shaking — the80 rule is steering you correctly.81- **kebab-case filenames**, **`interface` over `type`**, **no `enum`** (const82 objects / unions), **no `any`**, **no `!`**, **`node:` import protocol**,83 **type-only `import type`/`export type`** — all enforced; just write to them.84- Worktree/scratch dirs (`.omx`, `.omo`) → put them in `.gitignore`; ultracite85 sets `vcs.useIgnoreFile: true`, so Biome skips them **without** a Biome ignore.8687See [`references/templates.md`](references/templates.md) for the full list of what ultracite enforces.8889## 4. Build with tsdown + `tsc --noEmit`9091- **tsdown** (Rolldown/Oxc based — the modern successor to tsup) is the only92 build tool. `dts: true`, `sourcemap: true`, `unbundle: true` for93 file-per-module output (pairs well with subpath exports).94- **`tsc` never emits.** `"typecheck": "tsc --noEmit"`. The bundler owns output.95- Default to **ESM-only** (`"type": "module"`, exports use `import`). Ship CJS96 only if you actually have CJS consumers — and then nest `types` correctly per97 branch (`.d.ts` vs `.d.cts`); see the dual template.9899## 5. Internal deps: the namespaced source-condition (monorepo only)100101**Verdict: keep it.** It's the Colin McDonnell / TypeScript-endorsed way to get102zero-build "live types" across a monorepo, and unlike `publishConfig.exports` it's103package-manager-agnostic. But it has sharp edges — wire **all four** of these or it104silently resolves to stale `dist`:105106```jsonc107// each publishable package's package.json — condition FIRST, then types, then import108"exports": {109 "./package.json": "./package.json",110 ".": {111 "@minpeter/source": "./src/index.ts",112 "types": "./dist/index.d.ts",113 "import": "./dist/index.js"114 }115}116```1171. **Root `tsconfig.json`**: `"customConditions": ["@minpeter/source"]` (editor + `tsc --noEmit` resolve to source).1182. **Build config** (`tsconfig.build.json`, used by tsdown): **must NOT** carry the condition, or your `.d.ts` resolves sibling deps to `.ts`.1193. **tsx**: `tsx --conditions @minpeter/source` (tsx does **not** read tsconfig — it'll silently miss otherwise).1204. **Vitest**: `resolve: { conditions: ["@minpeter/source"] }`.121122**Guardrails (non-negotiable):**123- Namespace the condition (`@minpeter/source`, never bare `source`) so it can't collide.124- TypeScript does **not** error if the `src` file is missing — it silently falls125 back. So run **`attw --pack`** (`@arethetypeswrong/cli`) in CI on every126 publishable package to prove external consumers get correct `dist` types.127128Full rationale, the contested history, every footgun, and citations:129**[`references/source-condition.md`](references/source-condition.md).** (Single-package repos: skip all of this.)130131## 6. Publish: npm OIDC trusted publishing (mandatory)132133**No `NPM_TOKEN`. Ever.** Releases authenticate via GitHub OIDC; provenance is134attached automatically.135136- Release job needs `permissions: { id-token: write, contents: read }`,137 the Node runtime currently recommended by npm's trusted-publishing docs, and138 `npm@latest` installed immediately before publishing.139- **Repo-level gate:** Settings → Actions → General → Workflow permissions →140 enable **"Allow GitHub Actions to create and approve pull requests"**. New repos141 default to read-only with PR creation off; without this, the CI run that opens142 the Version Packages PR fails with 403 even when the workflow declares143 `pull-requests: write`.144- `changeset publish` works under OIDC; do **not** set `NODE_AUTH_TOKEN` on the145 publish step (npm only uses OIDC when the auth token env is unset).146- `publishConfig: { "access": "public", "provenance": true }` per package.147- **One-time setup:** configure the package's *Trusted Publisher* on npmjs.com148 (org/user + repo + workflow filename). **Caveat:** OIDC can't publish a149 package's *first ever* version — do the initial publish with a token, then150 switch to OIDC.151152Step-by-step + the release workflow: **[`references/publishing-oidc.md`](references/publishing-oidc.md).**153154## 7. Review checklist (use when auditing an existing repo)155156- [ ] pnpm resolved from latest and recorded in `packageManager`; `pnpm-workspace.yaml` holds pnpm settings157- [ ] `biome.jsonc` extends ultracite and has **zero** overrides/ignores/`biome-ignore`158- [ ] No barrel files; public API via subpath exports159- [ ] Build is **tsdown**; `tsc` is `--noEmit` only160- [ ] (monorepo) source-condition wired in all 4 places; build tsconfig has none; `attw` in CI161- [ ] `exports` order: custom condition → `types` → `import`/`require` (types nested per branch if dual)162- [ ] `type: module`, `sideEffects: false`, `files: ["dist"]`, `publishConfig.provenance: true`163- [ ] Release uses **OIDC** (`id-token: write`), no `NPM_TOKEN`, current npm trusted-publishing runtime guidance, and `npm@latest`164- [ ] kebab-case filenames; `interface` over `type`; no `enum`/`any`/`!`; `node:` imports165- [ ] Vitest + v8 coverage; Changesets configured166167## NOT this style (flag in review)168169ESLint / Prettier · tsup or raw-`tsc` library builds · npm/yarn/bun lockfiles ·170`NPM_TOKEN` secret for publishing · `// biome-ignore` or biome `overrides` ·171barrel `index.ts` re-export files · default-export-heavy modules ·172PascalCase/snake_case filenames · `enum` · `any` · non-null `!`.