Ein Schema.
Von Postgres bis zu React-Props.
Das Versprechen "End-to-End-TypeScript" gibt jedes moderne Framework — und bricht es dann klammheimlich an der Datenbankgrenze. Voltro hält das Versprechen: Tabellen, Mutations, Queries, Fehler, AI-Tool-Inputs, Workflow-Payloads — alles über effect/Schema definiert, alles typisiert bis zum Client, ohne einen Codegen-Schritt, den du auszuführen vergessen kannst.
// 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 }
}
}Sechs Garantien, die der Compiler für dich durchsetzt.
Effect Schema überall
Eine Schema-Bibliothek — effect/Schema — definiert Tabellen, Mutations, Queries, Workflow-Payloads, RPC-Fehler und AI-Tool-Inputs — und die Opt-in-gRPC-Oberfläche emittiert ihr .proto aus denselben Schemas, Feldnummern gepinnt durch ein eingechecktes Manifest. Kein tRPC, kein Zod-vs-Yup-vs-Joi-Wildwuchs, keine Schema-Brücken.
Branded TypeIDs
id()-Spalten liefern Branded Types (UserId, OrgId) — nie rohe Strings. Übergibst du eine OrgId an eine Funktion, die eine UserId erwartet, schlägt die Kompilierung fehl. Fremdschlüssel tragen den Brand mit.
Kein Codegen-Schritt
Die RPC-Group schreibt sich bei jedem voltro-dev-Start selbst. Dein Client importiert einfach `useMutation('app', 'users.setAge')` — der Schema-Typ fließt durch. Kein `pnpm codegen`. Kein CI-Fehler, wenn jemand vergisst, es auszuführen.
Typisierte Fehler per Pattern-Matching
Deklariere error: AgeOutOfRange an einer Mutation. Wirf die Klasse auf dem Server. instanceof auf dem Client grenzt auf die typisierten Felder ein. Genauso für Tagged Errors via Effect.catchTag.
Relationstypen fließen durch .with()
database.users.with(...) — mit deklarierten verschachtelten Relationen wie profile und posts.author — liefert User[] mit einer voll typisierten, verschachtelten Form: Profile | null bei jedem, Author bei jedem Post. Beliebige Tiefe.
Auto-optimistische Patches sind typisiert
Die target:-Deklaration an einer Mutation steuert den Cache-Patch-Reducer mit demselben Typ wie die Row-Type der Subscription. Du kannst keine Zeile in falscher Form voranstellen; es kompiliert nicht.
Der Codegen-Schritt, den du vergisst, ist der Bug, den dein Kunde findet.
Der klassische tRPC-/GraphQL-Codegen-Workflow hat eine Schwachstelle: Jemand vergisst, vor dem Push `pnpm codegen` auszuführen. Der Client kompiliert gegen das alte Schema. CI ist grün. Production ist kaputt. Voltros Codegen läuft bei jedem voltro-dev-Start UND bei jeder Dateiänderung — es gibt keinen separaten Schritt, den du verpassen kannst.
Drei Ebenen, auf denen Voltros Typen Bugs verhindern, die deine Tests nicht fangen würden:
- • Branded IDs. Ein Handler mit orgId: string akzeptiert alles, was ein Aufrufer übergibt. Ein Handler mit orgId: OrgId fängt den Bug direkt an der Aufrufstelle, noch bevor der Test überhaupt läuft.
- • Typisierte Fehler. Ein Client, der catch (e: unknown) macht und if (e.message === '...') schreibt, driftet bei jeder Änderung der Fehlermeldung. Pattern-Matching auf eine getaggte Klasse nie.
- • Relation-Eager-Loading. Eine Query, die user.profile.firstName zurückgibt, deren Eager-Spec aber nur user.profile.id lädt, scheitert zur Laufzeit — undefined.firstName. Voltros .with()-Spec typisiert das Ergebnis, sodass der Zugriff geprüft wird.
Woher die Typen kommen.
$ 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 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 redEin generierter Client ist genau so oft korrekt, wie jemand daran denkt, ihn neu zu erzeugen — und das Versagen ist still: der Editor ist sich über eine Form sicher, die der Server vor drei Commits aufgehört hat zurückzugeben. Ableitung hat keinen Schritt zum Vergessen, die Uneinigkeit wird also ein roter Build statt einer Laufzeitüberraschung.
End-to-End-Typsicherheit im Detail.
Was heißt End-to-End-Typsicherheit hier konkret?
Dass eine Änderung an deinem Schema oder an der Signatur einer Procedure die Kompilierung überall dort bricht, wo es nicht mehr passt — auch im Browser — bevor sie zur Laufzeit brechen kann. Nicht „der Client hat Typen“, sondern „die Typen des Clients sind aus denen des Servers abgeleitet, sie können also nicht veralten“.
Es gibt kein generiertes SDK zum Neuerzeugen und kein gemeinsames Interface-Paket, das man per Konvention synchron hält. Der Client liest die Procedure-Map der api direkt — eine umbenannte Mutation oder ein neues Pflichtfeld taucht als Typfehler in der aufrufenden Komponente auf.
Dieselbe Ableitung reicht bis in die Datenbank. Die Deklaration einer Spalte erzeugt den Zeilentyp, den dein Handler sieht — eine entfernte Spalte lässt also keinen Handler fröhlich eine Property lesen, die es nicht mehr gibt.
Sind die Typen nur Compile-Zeit, oder wird etwas validiert?
Beides — und genau an dieser Unterscheidung hören die meisten „typsicheren“ Stacks auf. Input und Output einer Procedure sind Schemas, also echte Laufzeit-Validatoren: die Grenze wird mit tatsächlichen Daten geprüft, nicht bloß in einem Typ beschrieben, der beim Build verschwindet.
Ein Input, den das Schema nicht beschreibt, wird ABGELEHNT statt beschnitten. Das wurde in 0.37 bewusst geändert: ein unbekanntes Feld still zu verwerfen hieß, dass ein Client mit umbenannter Property eine erfolgreiche Antwort bekam, die die Hälfte des Gesendeten ignorierte — schlimmer als ein Fehler.
Auch Outputs werden validiert, an der Grenze statt beim Konsumenten. Ein Handler, der die falsche Form zurückgibt, ist ein Defekt, der dort gemeldet wird, wo er entstand — statt eines Clients drei Schichten weiter, der an einer undefinierten Property scheitert, ohne Spur zur Ursache.
Was ist mit den Teilen, die sonst Strings sind?
Die meisten davon sind hier typisiert. Procedure-Tags vervollständigen sich und ein Tippfehler ist ein Compile-Fehler; die Felder eines Schemas treiben Formular-Bindings und Tabellenspalten; Übersetzungskataloge sind gegen die Default-Locale typisiert, ein fehlender Key kippt also den Build; Umgebungsvariablen sind deklariert und validiert, öffentliche auf Typebene von Secrets getrennt.
Route-Parameter werden aus dem Pfad erschlossen — eine Seite, die einen Parameter liest, den ihre Route nicht deklariert, kompiliert nicht. Deep-Link-Muster funktionieren genauso: die Params, die ein Handler bekommt, werden aus den Segmenten des Musters abgeleitet. Und der Query-String gehört zum selben Vertrag: eine Seite kann ein Search-Param-Schema deklarieren, Reads kommen dekodiert und mit Defaults zurück, und ein Link auf diese Route typprüft seine Params — ein vertippter Key oder falscher Wert-Typ ist ein Compile-Fehler, kein stillschweigend ignorierter Filter.
Der Punkt ist nicht, dass alles um seiner selbst willen typisiert sein müsste. Es sind die Stellen, an denen ein Fehler im Review unsichtbar und billig zu machen ist: ein umbenannter Key, ein weggefallenes Feld, ein Parameter, den es mal gab.
Wo hört das Framework auf, Typen zu vertrauen?
An jeder Vertrauensgrenze. Typen beschreiben Absicht; gegen Daten von außen können sie sie nicht erzwingen. Also wird Input zur Laufzeit validiert, Zugriff ist eine deklarierte Entscheidung, die der Boot prüft — kein Typ — und Tenant-Scoping wird von der Runtime ins Query gemerged, statt einem korrekt getypten Aufruf überlassen zu werden.
Deshalb gilt im Framework-Code ein Cast als Defekt und nicht als Abkürzung. Ein Context-Objekt, das gecastet wird, um den Compiler zufriedenzustellen, ist eine Stelle, an der zwei Codepfade auseinandergehen können, ohne dass es jemand merkt — genau diesen Fehler gab es hier: einem Produktionspfad fehlten Felder, die der Entwicklungspfad hatte, und der Cast war das, was es kompilieren ließ.
Und ein grüner Testlauf ist kein grüner Build. Test-Runner transpilieren ohne Typprüfung — ein Typfehler in einer Testdatei besteht also die Suite und kippt CI. Die Disziplin des Frameworks ist, den Typecheck separat zu fahren, Testdateien eingeschlossen.
Woher die Typen kommen.
| Oberfläche | Abgeleitet aus |
|---|---|
| Client-Aufrufe | Der Procedure-Map der api — kein generiertes SDK, eine Umbenennung ist ein Compile-Fehler beim Aufrufer. |
| Zeilentypen | Der Schema-Deklaration — eine entfernte Spalte bricht den Handler, der sie gelesen hat. |
| Wire-Payloads | Input- und Output-Schemas, zur Laufzeit in beide Richtungen validiert. |
| Route-Parameter | Dem Pfad selbst — eine Seite kann keinen Parameter lesen, den ihre Route nicht deklariert. Search-Params kommen per Page-Schema dazu: dekodierte Reads, typgeprüfte Links. |
| Übersetzungen | Der Key-Menge der Default-Locale; andere Kataloge sind dagegen typisiert. |
| Umgebung | Einem deklarierten Env-Schema, öffentliche Variablen auf Typebene von Secrets getrennt. |
Häufige Fragen
Muss ich ein Client-SDK generieren?
Nein. Der Client liest die Procedure-Map der api direkt — es gibt nichts neu zu erzeugen und nichts, das veralten kann. Eine Umbenennung zeigt sich als Typfehler in der aufrufenden Komponente statt als 404 zur Laufzeit.
Ist die Validierung dasselbe wie die Typen?
Sie ist aus derselben Deklaration abgeleitet, wird aber zur Laufzeit durchgesetzt — genau das, was Typen nicht können. Nicht passender Input wird abgelehnt, auch Felder, die das Schema nicht beschreibt, und Outputs werden an der Grenze geprüft, wo eine Abweichung ein Defekt mit Ort ist.
Verlangsamt das den Build?
Typechecken ist ein eigener Schritt vom Ausführen — genau deshalb besteht das Framework darauf: ein Test-Runner transpiliert ohne Typprüfung, eine grüne Suite ist also nur die halbe Wahrheit. Du fährst den Typecheck über die App inklusive Testdateien.
Was, wenn ich eine Notluke brauche?
Die gibt es, und die Regel des Frameworks ist, einen Cast auf ein Context- oder Grenzobjekt als Defekt statt als Bequemlichkeit zu behandeln — diese Art Cast hat in der Geschichte dieser Codebasis einen Produktionspfad still von einem Entwicklungspfad abweichen lassen.
Reicht die Typsicherheit bis zu einem Mobile-Client?
Ja, über dieselbe Procedure-Map: eine React-Native-App ruft dieselben typisierten Hooks auf wie der Web-Client. Für vollständig native Clients werden Swift- und Kotlin-SDK-Pakete aus derselben api-Oberfläche generiert.
Typen fließen durch jedes andere Primitiv.
Schema-DSL
Tabellen-Deskriptoren sind die Source of Truth. Branded IDs, JSON-typisierte Spalten, oneOf()-Literal-Narrowing — alles durchtypisiert.
Reaktive Queries
useSubscription liefert dieselbe Form, die die Query des Servers deklariert. Eager-geladene Relationen sind typisiert.
Durable Workflows
Workflow-Payload- und Success-Typen fließen über die Leitung, wenn sie aus einer Mutation ausgelöst werden. Kein Retype-Cast an der Grenze.
Öffne das Framework. Schau es dir selbst an.
Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.