Thanks for your interest in improving unthrown. This is a small, focused
library — the guiding principle is one concept = one name, and the surface is
meant to stay small enough that the library can be "done". Contributions that
sharpen the existing design are more welcome than ones that grow it.
- Node
>=22.19 - pnpm
11.7.0(pinned viapackageManager; runcorepack enableto get it)
git clone https://github.com/btravstack/unthrown.git
cd unthrown
pnpm installEvery change must keep all of these green (CI runs the same set):
pnpm format --check # oxfmt
pnpm lint # oxlint
pnpm typecheck # tsc (incl. type-level tests)
pnpm knip # dead code / unused deps
pnpm test # vitest (+ v8 coverage)
pnpm build # tsdown dual CJS/ESM + d.tsRun pnpm format (no --check) to auto-fix formatting.
The core package holds 100% line/function coverage, enforced by thresholds in
its vitest.config.ts. Every load-bearing runtime invariant (see CLAUDE.md) is
guarded 1:1 in packages/core/src/invariants.spec.ts, and type-level behaviour is
asserted in packages/core/src/types.test-d.ts. If you change behaviour, update
or add the matching guard.
CLAUDE.md is the authoritative spec — the rules and the reasoning. Read it
before proposing a change. In particular:
- oxlint rules are binding: no
interface(usetype), noany(useunknown). Genuine exceptions carry a targetedoxlint-disablewith a reason. - Core has zero runtime dependencies — the exhaustive error matcher is
built in (
packages/core/src/matcher.ts, exportingmatch/P/NonExhaustiveErroras core's own). It replaced a formerts-patterndependency deliberately: the exhaustiveness guarantee must not vary with a consumer-resolved third-party version. Add no dependencies — never pullvitestor any interop peer into core. - One name per concept. Resist convenience aliases.
- Public API carries full TSDoc;
pnpm --filter <pkg> build:docsmust stay warning-free.
If your change contradicts something in CLAUDE.md, either the change or the spec
is wrong — resolve that in the PR discussion, and keep CLAUDE.md in sync (it
describes what is, not what was planned).
Commits follow Conventional Commits and are
checked by commitlint via a lefthook commit-msg hook. Examples:
feat(core): add flatTapErr combinator
fix(interop): let a Defect dominate in allFromDictAsync
docs: clarify the qualify boundary
chore(deps): bump vitest
User-facing changes need a changeset so the release notes and version bumps are generated correctly:
pnpm changesetPick the affected packages and a semver bump, and describe the change in one line. Purely internal changes (tests, CI, refactors with no API/behaviour impact) don't need one.
When a change is breaking, or a batch of changes needs to be validated in a real
consumer before it becomes a stable major, publish it as a beta first instead
of cutting the major straight to latest. This uses changesets pre
mode; the
seven packages in the fixed group move together to the same beta version
(@unthrown/prisma and @unthrown/orpc sit outside it — their majors track
Prisma's and oRPC's cadence, not the family's).
The release pipeline needs no changes — while a .changeset/pre.json is
present, changeset publish (run by release.yml) publishes under the beta
dist-tag instead of latest automatically. Because every package already has a
stable release on npm, none of them fall back to latest.
# 1. Enter pre mode (once, from a clean main). Commit the .changeset/pre.json it
# writes and merge it — from here every release is a beta.
pnpm changeset pre enter beta
# 2. Iterate. Add changesets as usual; each merged release PR (the one this repo's
# workflow titles "chore: release packages") ships the next x.y.z-beta.N to the
# `beta` tag. Do NOT delete accumulated changesets — they are all consumed to
# build the final changelog at exit.
# 3. Graduate. The next "chore: release packages" PR after this cuts the stable
# version to `latest`.
pnpm changeset pre exitConsume a beta from a downstream project with:
pnpm add unthrown@betaNote: while in pre mode on
mainyou cannot ship a stable patch tolatestuntilpre exit— every release is a beta. That is the intended trade for batching breaking changes toward a major. Running pre mode on a dedicatednextbranch instead is possible but not a one-line change: the release pipeline is currently hard-wired tomainin three places —baseBranchin.changeset/config.json, theworkflow_runtrigger inrelease.yml, and the changesets action'sbranchinput would need to point atnext— so that setup is out of scope for this note.
- Keep PRs focused — one concern each.
- Make sure the full gate passes locally before pushing.
- Reference the issue you're addressing, if any.
By contributing, you agree that your contributions are licensed under the project's MIT License.