TypeFirst is a methodology — and ts-bedrock, its production-ready full-stack template — for building TypeScript apps with end-to-end type safety. One API contract shared by server and client. No any. No as. No runtime surprises.
Created by Haniker.dev · battle-tested in production on client projects by AgileLabs
// Banned by lint — not convention: any · as · is · !
const key: unique symbol = Symbol()
export type Email = Opaque<string, typeof key>
const email: Email = "alice@example"
// ^^^^^ ✗ Type error — a string is not an Email
const email = createEmailE("alice@example.com")
// ✓ Result<ErrorEmail, Email> — failure is a value,
// not a throw How it works
Every endpoint is defined once as a typed contract — method, route, params, payload, and every error code. The server handler and the browser client both import it, so they cannot drift apart. No codegen. No OpenAPI sync step. Just imports.
// Core/Api/Public/Login.ts
export type Contract = Api<
"POST", "/login",
NoUrlParams, BodyParams,
ErrorCode, Payload
>
export type ErrorCode =
| "USER_NOT_FOUND"
| "INVALID_PASSWORD"
export type Payload = {
user: User
accessToken: AccessToken
refreshToken: RefreshToken
} // Api/src/Api/Public/Login.ts
// A pure function — no Express,
// no mocks needed to test it.
export async function handler(
params: API.BodyParams,
): Promise<Result<API.ErrorCode, API.Payload>> {
const { email, password } = params
const userRow = await getByEmail(email)
if (userRow == null)
return err("USER_NOT_FOUND")
const valid = await Hash.verify(…)
if (valid === false)
return err("INVALID_PASSWORD")
return ok(await loginPayload(userRow))
} // Web/src/Api/Public/Login.ts
export async function call(
params: BodyParams,
): Promise<Response> {
return publicApi(contract, {}, params)
}
// Error handling is exhaustive —
// forget a code and it won't compile:
switch (errorCode) {
case "USER_NOT_FOUND":
return "User is not found."
case "INVALID_PASSWORD":
return "Password is incorrect."
} Principles
Every principle below is enforced by ESLint and the type system. New team members can't accidentally opt out.
any as is ! No escape hatches. Type coercion and non-null assertions are lint errors with --max-warnings=0. If it compiles, the types are real.
Failure is a value. Expected errors return Result<E, T> or Maybe<T>, so every caller is forced to handle them. Exceptions are reserved for the truly unrecoverable.
API responses, request bodies, URL params, env vars, form input — all unknown data is decoded into known types before it touches your logic. Nothing is trusted.
An Email is not a string. Validated values are wrapped in opaque types with smart constructors, so unvalidated data can't impersonate validated data.
App state is a sum type: you can be logged out or logged in — never "logged in with no user". The states you didn't design can't be represented.
Making them unrepresentable →API handlers run without Express. Views render without hooks — the frontend follows The Elm Architecture, so state changes only through actions. Everything is unit-testable.
We banned React hooks →TypeSpec
TypeFirst structures an entire application as five layers of type definitions. Each layer can only depend on the ones below it — enforced by ESLint boundary rules.
Domain types used everywhere — User, Email, Timestamp.
Typed table rows and queries via Kysely.
/Api/src/DatabaseRequest, response and error codes for every endpoint.
/Core/ApiApp state as sum types — impossible states excluded.
/Web/src/StateEvery state transition is a typed, pure action.
/Web/src/ActionThe template
Not a toy. ts-bedrock ships a full authentication flow — login, JWT access tokens, rotating refresh tokens, profile management — across all three layers, with parallel isolated test databases.
Shared domain types and API contracts. Zero framework dependencies.
Express 5 backend with pure, framework-free handlers.
React + Vite frontend on The Elm Architecture.
Layer boundaries are enforced by eslint-plugin-boundaries: Core imports nothing, Api and Web import only Core and themselves. Circular dependencies are impossible by construction.
Honest comparison
They're excellent tools — and if they fit your team, use them. TypeFirst makes a different trade: fewer dependencies, more explicit types, and a discipline that covers your whole app, not just the API boundary.
| TypeFirst / ts-bedrock | tRPC | Zod + REST | Typical React + Express | |
|---|---|---|---|---|
| End-to-end types, no codegen | ✓ shared contracts | ✓ via router inference | Partial — schemas shared manually | ✗ |
| Typed error codes per endpoint | ✓ exhaustive switch on errors | Limited — error shapes are generic | Manual | ✗ |
| Runtime validation at every boundary | ✓ decoders everywhere, lint-enforced | At procedure inputs | Where you remember to | ✗ |
any / as / ! banned | ✓ lint error, zero warnings | Your call | Your call | Your call |
| Frontend state discipline | ✓ The Elm Architecture, pure views | Bring your own | Bring your own | Hooks ad hoc |
| Built-in learning curriculum | ✓ exercises + lectures included | ✗ | ✗ | ✗ |
| Ecosystem & community size | Small and opinionated — by design | Large | Large | Huge |
The honest trade-off: TypeFirst asks you to learn a stricter way of working and gives up some ecosystem convenience. In exchange, entire classes of bugs — API drift, null surprises, unhandled errors, impossible UI states — stop existing.
Learn TypeFirst
The repo includes /Learn — guided lectures and hands-on exercises that take a TypeScript developer from "I use types" to "I think in types". The same material AgileLabs uses to onboard engineers. Every lesson below is also a full tutorial on the blog.
Why mutation breeds bugs, and how to work without it.
02Model your domain so impossible cases can't exist. Cardinality thinking.
03Write functions once, keep them fully typed for every use.
04Turn untrusted unknown data into guaranteed types at runtime.
Build values that are correct by construction — validated once, trusted forever.
→Graduate into ts-bedrock itself — a 16-part series rebuilds it from scratch, file by file.
Production-proven
Start a project
ts-bedrock is a template, not a framework — you copy it once and own every line. Four steps from nothing to shipping features.
Clone once, then cut the cord. There's no CLI and no framework dependency to track — this is your codebase now.
git clone --depth 1 \
https://github.com/haniker-dev/ts-bedrock.git my-app
cd my-app
rm -rf .git && git init Postgres runs in Docker; Api and Web start concurrently. You have a working login flow before you write a line.
npm install
(cd Api && npm install)
(cd Web && npm install)
npm run external:start # Postgres via Docker
npm run db:migrate
npm start # Api :3001 + Web (Vite) Set your secrets, keep what helps, delete what doesn't:
JWT_SECRET and your DB credentials in devops/dev/.env.development/Learn for onboarding teammates, or delete it — nothing depends on itThe everyday loop. Define the contract first and the compiler walks you through the rest:
/Core/Api — copy _Sample.ts as a starting point/Api/src/Api/Api/src/Route.ts/Web/src/Api — then npm run tsc && npm test