npm install discipline

TypeScript where invalid states
don't compile.

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

Core/Data/User/Email.ts
// 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

One contract. Compiler-checked from database to DOM.

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.

1 · Core

Define the contract

// 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
}
2 · Api

Fulfil it on the server

// 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))
}
3 · Web

Call it from the client

// 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."
}
Rename an error code in Core and both sides fail to compile. That's the whole point — API drift between frontend and backend becomes a compile error, not a production incident.

Principles

Discipline, enforced by the compiler — not the code review.

Every principle below is enforced by ESLint and the type system. New team members can't accidentally opt out.

🚫

Banned: 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.

Why we banned all four →
↩️

Never throw

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.

Result types in production →
🛂

Decode every boundary

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.

How decoders work →
💎

Opaque types

An Email is not a string. Validated values are wrapped in opaque types with smart constructors, so unvalidated data can't impersonate validated data.

The feature you're not using →
🧱

Impossible states don't compile

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 →
🧮

Pure functions everywhere

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

Five levels of types, one source of truth.

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.

Type-1

Core types

Domain types used everywhere — User, Email, Timestamp.

/Core/App · /Core/Data
Type-2

Database types

Typed table rows and queries via Kysely.

/Api/src/Database
Type-3

API contracts

Request, response and error codes for every endpoint.

/Core/Api
Type-4

Frontend state

App state as sum types — impossible states excluded.

/Web/src/State
Type-5

Frontend actions

Every state transition is a typed, pure action.

/Web/src/Action

The template

ts-bedrock: a complete, working reference.

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.

/Core

Shared domain types and API contracts. Zero framework dependencies.

  • Opaque types & smart constructors
  • Result · Maybe · RemoteData
  • Typed API contracts with decoders
/Api

Express 5 backend with pure, framework-free handlers.

  • Kysely + PostgreSQL, fully typed schema
  • bcrypt + JWT (jose) + refresh token rotation
  • Handlers tested without HTTP mocks
/Web

React + Vite frontend on The Elm Architecture.

  • State changes only through typed actions
  • No hooks — pure views (lint-enforced)
  • Type-safe routing & RemoteData UI states

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

Why not tRPC, Zod, or Effect?

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

A curriculum ships with the code.

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.

Production-proven

Backed by AgileLabs.
Shipped to real clients.

  • Multiple client projects at AgileLabs are built and deployed on ts-bedrock.
  • Maintained because a business runs on it — not a weekend experiment.
  • Used to onboard real teams — the Learn curriculum is how AgileLabs trains engineers into the methodology.

What that shipping work taught us — the production series →

“We stopped debugging API mismatches and null crashes, because they stopped compiling. TypeFirst is how we ship client work with small teams and sleep at night.” — AgileLabs engineering

Start a project

From zero to your own full-stack app.

ts-bedrock is a template, not a framework — you copy it once and own every line. Four steps from nothing to shipping features.

Step 1

Make it your repo

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
Step 2

Install and boot

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)
Step 3

Make it yours

Set your secrets, keep what helps, delete what doesn't:

  • Set a fresh 64+ character JWT_SECRET and your DB credentials in devops/dev/.env.development
  • Keep the auth flow — login, JWT, rotating refresh tokens and profile are production-ready scaffolding, not demo code
  • Keep /Learn for onboarding teammates, or delete it — nothing depends on it
Step 4

Add your first endpoint

The everyday loop. Define the contract first and the compiler walks you through the rest:

  • Define the contract in /Core/Api — copy _Sample.ts as a starting point
  • Implement the pure handler in /Api/src/Api
  • Bind it in /Api/src/Route.ts
  • Call it from /Web/src/Api — then npm run tsc && npm test

Full walkthrough of the contract pattern →