One schema.
From Postgres to React props.

The promise "end-to-end TypeScript" gets made by every modern framework, then quietly broken at the database boundary. Voltro keeps the promise: tables, mutations, queries, errors, AI tool inputs, workflow payloads — all defined through effect/Schema, all flowing through to your client without a codegen step you can forget to run.

users.setAge.mutation.ts
TypeScript
// 1. Define the table — types live in the schema.
export const users = table('users', {
  id:    id(),                              // UserId (branded)
  email: text().unique(),                   // string
  age:   integer().nullable(),              // number | null
})

// 2. Define the mutation — input/output schemas.
export const setAge = defineMutation({
  name:   'users.setAge',
  input:  Schema.Struct({ id: UserId, age: Schema.Number }),
  output: Schema.Struct({ id: UserId, age: Schema.Number }),
  error:  AgeOutOfRange,
})

// 3. On the client — fully typed, zero codegen.
const setAgeMutation = useMutation('app', 'users.setAge')
//    ^? MutationBuilder<{ id: UserId; age: number }, ...>

try {
  const { id, age } = await setAgeMutation.mutate({ id, age: 12 })
} catch (e) {
  if (e instanceof AgeOutOfRange) {
    // e.range is typed: { min: number; max: number }
  }
}

Six guarantees the compiler enforces for you.

Effect Schema everywhere

One schema library — effect/Schema — defines tables, mutations, queries, workflow payloads, RPC errors, AI tool inputs — and the opt-in gRPC surface emits its .proto from the same schemas, field numbers pinned by a checked-in manifest. No tRPC, no Zod-vs-Yup-vs-Joi split, no schema bridges.

Branded TypeIDs

id() columns return branded types (UserId, OrgId) — never raw strings. Passing an OrgId to a function that expects a UserId fails compilation. Foreign keys flow with the brand.

No codegen step

The RPC group writes itself on every voltro dev boot. Your client just imports `useMutation('app', 'users.setAge')` — the schema type flows through. No `pnpm codegen`. No CI failure when someone forgets to run it.

Typed errors via pattern-matching

Declare error: AgeOutOfRange on a mutation. Throw the class on the server. instanceof on the client narrows to the typed fields. Same for tagged errors via Effect.catchTag.

Relation types flow through .with()

database.users.with(...) — declaring nested relations like profile and posts.author — returns User[] with a fully-typed nested shape: Profile | null on each, Author on each post. Arbitrary depth.

Auto-optimistic patches are typed

The target: declaration on a mutation drives the cache patch reducer with the same type as the subscription's row type. You can't prepend a row of the wrong shape; it won't compile.

The codegen step you forget is the bug your customer finds.

The classic tRPC / GraphQL-codegen workflow has one weak link: someone forgets to run `pnpm codegen` before push. The client compiles against the old schema. CI is green. Production is broken. Voltro's codegen runs on every voltro dev boot AND on every file change — there's no out-of-band step you can miss.

Three layers where Voltro's types prevent bugs your tests wouldn't catch:

  • Branded IDs. A handler that takes orgId: string accepts whatever a caller passes. A handler that takes orgId: OrgId catches the bug at the call-site, before the test even runs.
  • Typed errors. A client that catch (e: unknown) and writes if (e.message === '...') drifts on every error-message edit. Pattern-matching on a tagged class never does.
  • Relation eager-loading. A query that returns user.profile.firstName but the eager spec only fetches user.profile.id fails at runtime — undefined.firstName. Voltro's .with() spec types the result so the access is checked.

Where the types come from.

A generated client you keep in sync
Generated
$ npm run generate:client   # …when you remember

// src/generated/api.ts — 4,000 lines, committed, reviewed by nobody
export interface Order { id: string; total: number }

// Three commits ago the server started returning `totalCents`.
// This still compiles. It fails in a browser, for a customer.
const order = await api.orders.get(id)
renderPrice(order.total)
The declaration IS the type
Inferred
// The procedure's own declaration is the contract.
export const getOrder = defineQuery({
  name:   'orders.get',
  input:  Schema.Struct({ id: OrderId }),
  output: Order,          // ← this, and nothing else, is the type
})

// In the component:
const { data: order } = useSubscription('app', 'orders.get', { id })
renderPrice(order.totalCents)
//                ^ rename it on the server and THIS line goes red

A generated client is correct exactly as often as someone remembers to regenerate it, and the failure is silent: the editor is confident about a shape the server stopped returning three commits ago. Inference has no step to forget, so the disagreement becomes a red build instead of a runtime surprise.

End-to-end type safety, in depth.

What does end-to-end type safety actually mean here?

That a change to your schema or a procedure's signature breaks compilation at every place that no longer fits — including in the browser — before it can break at runtime. Not "the client has types", but "the client's types are derived from the server's, so they cannot be stale".

There is no generated SDK to regenerate and no shared interface package to keep in sync by convention. The client reads the api's procedure map directly, so renaming a mutation or adding a required input field surfaces as a type error in the component that calls it.

The same derivation runs down to the database. A column's declaration produces the row type your handler sees, so removing a column does not leave a handler happily reading a property that no longer exists.

Are the types just compile-time, or is anything validated?

Both, and that distinction is where most "type-safe" stacks stop. A procedure's input and output are Schemas — real runtime validators — so the boundary is checked with actual data, not merely described in a type that vanishes at build.

An input the schema does not describe is REJECTED rather than trimmed. That changed deliberately in 0.37: silently discarding an unknown field meant a client sending a renamed property got a successful response that ignored half of what it sent, which is worse than an error.

Outputs are validated too, at the boundary rather than at the consumer. A handler that returns the wrong shape is a defect reported where it happened, instead of a client three layers away failing on an undefined property with no trail back to the cause.

What about the parts that are usually strings?

Most of them are typed here. Procedure tags autocomplete and a typo is a compile error; a schema's fields drive form bindings and table columns; translation catalogs are typed against the default locale so a missing key fails the build; environment variables are declared and validated, with public ones separated from secrets at the type level.

Route parameters are inferred from the path, so a page reading a parameter the route does not declare does not compile. Deep-link patterns work the same way — the params a handler receives are derived from the pattern's segments. And the query string is part of the same contract: a page can declare a search-param schema, reads come back decoded and defaulted, and a link to that route type-checks its params — a misspelt key or a wrong value type is a compile error, not a silently ignored filter.

The point is not that everything must be typed for its own sake. It is that these are the places where a mistake is invisible in review and cheap to make: a renamed key, a dropped field, a parameter that used to exist.

Where does the framework stop trusting types?

At every trust boundary. Types describe intent; they cannot enforce it against data that arrives from outside. So input is validated at runtime, access is a declared decision the boot verifies rather than a type, and tenant scoping is merged into the query by the runtime rather than left to a correctly-typed call.

That distinction is also why a cast is treated as a defect rather than a shortcut in framework code. A context object cast to satisfy the compiler is a place where two code paths can differ without anything noticing — the framework has had exactly that bug, where a production path was missing fields a development path had, and the cast is what let it compile.

And a green test run is not a green build. Test runners transpile without typechecking, so a type error in a test file passes the suite and fails CI; the framework's own discipline is to run the typecheck separately, test files included.

Where the types come from.

What is typed, and what the type is derived from
SurfaceDerived from
Client callsThe api's procedure map — no generated SDK, so a renamed procedure is a compile error in the caller.
Row typesThe schema declaration, so a dropped column breaks the handler that read it.
Wire payloadsInput and output Schemas, validated at runtime in both directions.
Route paramsThe path itself — a page cannot read a parameter its route does not declare. Search params join via a page-level schema: decoded reads, type-checked links.
TranslationsThe default locale's key set; other catalogs are typed against it.
EnvironmentA declared env schema, with public variables separated from secrets at the type level.

Frequently asked questions

Do I need to generate a client SDK?

No. The client reads the api's procedure map directly, so there is nothing to regenerate and nothing that can be out of date. A rename shows up as a type error in the calling component rather than as a runtime 404.

Is the validation the same as the types?

It is derived from the same declaration but enforced at runtime, which is the part types cannot do. Input that does not match is rejected — including fields the schema does not describe — and outputs are checked at the boundary where a mismatch is a defect with a location.

Does this slow the build down?

Typechecking is a separate step from running, which is exactly why the framework insists on it: a test runner transpiles without checking types, so a green suite is only half of green. You run the typecheck over the app including test files.

What if I need an escape hatch?

They exist, and the framework's rule is to treat a cast on a context or boundary object as a defect rather than a convenience — that class of cast is what let a production code path silently differ from a development one in this codebase's own history.

Does type safety extend to a mobile client?

Yes, through the same procedure map: a React Native app calls the same typed hooks the web client does. For fully native clients, Swift and Kotlin SDK packages are generated from the same api surface.

Types flow through every other primitive.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.