ts-bedrock — the argument, in a repository
We had been building TypeScript projects the same way for years, and every new
one started with the same three days: set up the strictness, re-derive the
folder layout, re-explain to whoever joined why an Email is not a string.
The knowledge lived in our heads and in the last project we happened to
remember. That is not a methodology. That is a habit with good intentions.
So we wrote it down as a repository.
Five levels of types
The organising idea is TypeSpec: an application is specified, top to bottom, as five levels of types.
- T1 — core domain.
User,Email,Money. The types that mean the same thing everywhere: web, API, database, a script someone runs once. - T2 — database rows.
UserRowis theusertable, and it is a different type fromUser. The mapping between them is a function you can read. - T3 — API contracts. One value per endpoint: method, route, params, payload, and every error code it can return.
- T4 — frontend state. What the browser knows right now, as a sum type.
- T5 — actions. The transitions from one T4 to the next.
Each level has one home in the tree. Core/App holds T1, Core/Api holds T3,
Api/src/Database holds T2, Web/src/State and Web/src/Action hold T4 and
T5. When a new engineer asks where something goes, the answer is a lookup, not
a debate.
One Core, imported by both sides
Core is a plain TypeScript directory that both the Express API and the Vite
frontend import — not a generated client, not a copied interface, not an
OpenAPI schema kept in sync by a build step. The same value describes the
endpoint on both ends of the wire.
That single decision is where the guarantee comes from. Change a route, a parameter, an error code or a payload field, and every layer that now disagrees stops compiling. There is no drift to detect because there is no second copy to drift.
The API handlers underneath are pure functions from decoded params to
Result<ErrorCode, Payload> — no Express in their signature, so testing one
requires no mocks and no server.
The rules are machine-checked, not cultural
A style guide that lives in a wiki loses to a deadline. So the constraints are
lint errors with --max-warnings=0:
- no
any, noas, noispredicates, no!— the four ways TypeScript lets you assert instead of prove; - failure is a value:
Result<E, T>andMaybe<T>, notthrow; - everything crossing a boundary — request bodies, API responses, env vars,
database rows — is decoded from
unknowninto a known type before any logic touches it; - validated values are opaque, so an unvalidated string cannot impersonate one.
None of these are new ideas. What is new, for us, is that a tired engineer at 5pm cannot quietly opt out of any of them.
What it is for
ts-bedrock is not a framework you extend; it is the starting point we clone. It is deliberately small enough to read in an afternoon and boring enough to survive contact with a real client deadline. Everything we have learned about shipping typed software is either in that repository or is a bug in it.
github.com/haniker-dev/ts-bedrock — open source. The methodology it implements now has a home of its own at typefirst.io.