TypeScript Type Tree
Lightweight, branded table and action types generated directly from Quickback's compiler model.
Every compile emits quickback.gen.ts: a compact, type-only tree designed for application code. It is generated directly from the same compiler model that drives routes and OpenAPI, so there is no runtime package and no code to initialize.
import type { Api, Qb } from "../quickback.gen";
type Application = Api.applications.Row;
type NewApplication = Api.applications.Insert;
type ApplicationPatch = Api.applications.Update;
type ApplicationId = Api.applications.Id;
type AdvanceInput = Api.applications.actions.advance.Input;
type AdvanceOutput = Api.applications.actions.advance.Output;
type PipelineItem = Api.applications.views.pipeline.Item;
type PipelineResponse = Api.applications.views.pipeline.Response;
type Page = Qb.Page<Application>;
type ApiProblem = Qb.Problem;Feature areas preserve their authored hierarchy:
type Booking = Api.events.travel.bookings.Row;Table types
Each resource table exposes:
| Member | Meaning |
|---|---|
Id | Nominal table-specific primary-key type |
Row | Complete selected wire shape |
Insert | Createable fields, with defaults and nullable fields optional |
Update | Updatable fields, all optional |
ListResponse | Qb.Page<Row> |
columns.<name> | Type of one row column. A column named like a TypeScript keyword or built-in type (number, string, delete, …) is prefixed: columns._number |
views.<name>.Item | A view's projected row |
views.<name>.Response | Page plus typed aggregations where declared |
actions.<name>.Input | The action's Zod input contract |
actions.<name>.Output | The declared Zod output contract, or unknown |
Column types follow the wire, not the storage: q.bool() and
integer(..., { mode: "boolean" }) are boolean, integer(..., { mode: "timestamp" })
and q.timestamp() are ISO string, q.json() is unknown unless it is given a
value schema, enum columns
are literal unions, and nullable columns add | null.
Primary and foreign keys are branded by default. That makes accidentally passing a candidate ID where a job ID is required a TypeScript error, while both values remain ordinary strings or numbers at runtime.
A column is a foreign key here by the same rule the CMS and ?include= use:
a Drizzle .references(), an entry in the resource's references: map
({ vendorId: 'contact' }), or the <table>Id naming convention when a
table of that name exists.
function loadJob(id: Api.jobs.Id) {}
declare const candidateId: Api.candidates.Id;
loadJob(candidateId); // TypeScript errorAt an untyped boundary, validate the value and cast it once:
const jobId = routeParams.id as Api.jobs.Id;Auth references
Columns that point at a Better Auth principal are branded too, even though
those tables are not part of your project: tenant scopes (q.scope('organization'),
q.scope('owner'), q.scope('team'), or a raw Drizzle column bound by the
firewall — branded by the arm it binds, so an owner firewall on userId makes
it a Qb.UserId whatever the column is called), the audit stamps (createdBy,
modifiedBy, deletedBy), and any .references('user' | 'organization' | 'team')
column.
declare const job: Api.jobs.Row;
job.organizationId; // Qb.OrganizationId
job.createdBy; // Qb.UserId | null
function notify(userId: Qb.UserId) {}
notify(job.organizationId); // TypeScript error — an org id is not a user idMasked columns
Masking rewrites a column's value for callers outside its
show.roles. A plain string type would promise a value the runtime does not
deliver, so tables with masking rules take a Role type parameter on their
read shapes: Row<Role>, ListResponse<Role>, columns.<name><Role>, and
views.<name>.Item<Role> / Response<Role>. Writes (Insert, Update) are
unchanged — clients send real values.
// jobs.ts: masking: { internalNotes: { type: 'redact', show: { roles: ['admin'] } } }
declare const anyone: Api.jobs.Row; // Role defaults to never
declare const admin: Api.jobs.Row<"admin">;
declare const member: Api.jobs.Row<"member">;
anyone.internalNotes; // Qb.Masked<string> | null — may be redacted
admin.internalNotes; // string | null — revealed
member.internalNotes; // Qb.Masked<string> | nullQb.Masked<T> is a brand on the wire primitive: it is still assignable to
T (a masked email is meant to be rendered), but it is not T, so a
function that requires the real value cannot silently accept the default row.
Name the role you fetched with to get the revealed type; a union role
(Row<"member" | "admin">) yields T | Qb.Masked<T>.
A masked value narrower than a free string — a number, boolean, array, JSON
value or enum literal — cannot keep its type once masked: the redact mask
replaces it with the string "[REDACTED]". Qb.Masked<T> admits that
marker, typed Qb.Redacted, so narrow before using the value:
// masking: { salary: { type: 'redact', show: { roles: ['admin'] } } } — q.int()
declare const row: Api.jobs.Row;
row.salary; // Qb.Masked<number> | null (includes Qb.Redacted)
if (typeof row.salary === "number") console.log(row.salary * 12);OpenAPI documents the same thing: the column's schema becomes
anyOf: [<schema>, { type: "string", enum: ["[REDACTED]"] }]. String-shaped
masks (email, phone, …) can't mask such columns at all — see
Masking.
show: { or: 'owner' } reveals by record ownership, which no type parameter
can see, so those columns stay Masked for every role outside show.roles.
Reading collections
List and view responses share one envelope: rows under data, pagination
metadata under pagination, and view naming the projection behind the
response (null for the default collection read).
declare const res: Api.applications.ListResponse; // Qb.Page<Api.applications.Row>
res.data; // Api.applications.Row[]
res.view; // string | null
res.pagination.count; // rows in this page
res.pagination.hasMore;
res.pagination.nextCursor; // pass back as ?starting_after=
res.pagination.total; // number | undefined — only when ?total=truetotal and totalPages are counted only when the request passes
?total=true, so both are optional; every other member is always present. A
view that declares aggregations intersects this shape with a typed
aggregations object, and an aggregate-only view returns { view, aggregations } with no rows or pagination.
Declaring action outputs
Add an optional output Zod schema to make an action end-to-end typed. An
action that returns a row of a resource table declares it with row(<table>)
from the feature's helper — its Output is that table's Row, and OpenAPI
references the table's component:
import { defineAction, row, applications } from "../.quickback/define-action";
// Output = Api.applications.Row
output: row(applications)
// Nested works too: Output = { application: Api.applications.Row; created: boolean }
output: z.object({ application: row(applications), created: z.boolean() })The table can also come from its own table module, a sibling's, or another
feature's — row(payments) from payments/payments types as
Api.payments.Row and references the Payments component. See
Declaring action outputs.
For any other shape, write the schema out:
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
export default defineAction({
description: "Advance an application",
input: z.object({
nextStatus: z.enum(["screening", "interview", "offer"]),
}),
output: z.object({
id: z.string(),
status: z.enum(["screening", "interview", "offer"]),
}),
access: { roles: ["recruiter+"] },
async execute({ record, input }) {
return { id: record.id, status: input.nextStatus };
},
});The generated feature helper checks execute against the declared output at
compile time, and the generated route parses the result through it at
runtime (output.parse(result)) before responding. The declared type is
therefore the wire truth, not an annotation: keys the schema does not declare
are stripped, and a result that does not satisfy the schema is a 500 rather
than a silently different payload. This holds for allowRawSql actions too —
the raw query is unchecked, but what leaves the action is not. Actions with
responseType: 'stream' or 'file' return their result untouched.
The CLI harvests both schemas locally without invoking execute; the resulting contract feeds quickback.gen.ts, OpenAPI, and api-types.gen.ts.
Types are not transport
quickback.gen.ts is import type only: it describes the API, it does not
call it. A hand-rolled fetch still returns any, and nothing checks that
the URL you typed exists. For a client whose route table is the contract, use
api-types.gen.ts with openapi-fetch:
paths, methods, parameters, and response bodies are checked at the call site,
and both artifacts come from the same compiler model, so they cannot drift
from each other.
import createClient from "openapi-fetch";
import type { paths } from "../api-types.gen";
const api = createClient<paths>({ baseUrl: "https://api.example.com" });
const { data } = await api.GET("/api/v1/jobs/{id}", { params: { path: { id } } });Fetch client
Set client: true and every compile also emits quickback.client.ts: a
zero-dependency fetch client with one method per generated route, typed
through quickback.gen.ts. It has no React and no runtime package, and it is
the only generated code that knows the wire format (list envelopes, filter
syntax, Problem Details), so UI code built on it never builds a URL or parses a
response.
export default defineConfig({
client: true, // quickback.client.ts
// client: "apps/web/src/lib/api.ts", // or one custom path
// client: ["apps/web/src/api.ts", "apps/admin/src/api.ts"],
});client requires types — the client imports Api and Qb from the first
types output, and the compile fails if types is false. It is off by
default.
A CRUD verb mapped to an action
(create: { action: "send" }) keeps its method name but takes that action's
contract: create(body: Api.messages.actions.send.Input) returning
actions.send.Output, not Insert / Row. The action gets no second method.
TanStack Start denies `*.client.*` on the server
TanStack Start's import protection rejects any **/*.client.* module in the
server environment, which includes the SPA prerender, so importing the default
quickback.client.ts fails that build with Import denied in server environment. Give the client a name outside that pattern:
client: "src/lib/quickback-client.ts".
import { createClient, QuickbackError } from "./quickback.client";
const api = createClient({
baseUrl: "https://api.example.com", // paths already include /api/v1
// credentials: "include" by default — the Better Auth session cookie
// headers: () => ({ authorization: `Bearer ${token}` }),
});
// GET /api/v1/applications?stage.in=screen,offer&sort=-createdAt&total=true
const page = await api.applications.list({
filter: { "stage.in": ["screen", "offer"] },
sort: "-createdAt",
total: true,
});
page.data; // Api.applications.Row[]
page.pagination.total; // present because total: true
const app = await api.applications.get(id); // bare record
await api.applications.update(id, { notes: "…" }); // bare record
await api.applications.delete(id); // void ({ success: true } on the wire)
await api.applications.views.pipeline({ limit: 25 });
await api.applications.actions.advance(id, { notes: "Strong interview" });
try {
await api.applications.create({ candidateId, jobId });
} catch (err) {
if (err instanceof QuickbackError) {
err.status; // HTTP status
err.problem; // Qb.Problem when the body was application/problem+json, else null
err.fields; // { [field]: message } from details.fields — guards and validation alike — else null
}
}Batch, upsert and pagination
Where the batch routes exist, api.<x>.batch sends the documented
batch bodies; 207 partial success resolves (read
errors and meta.transactional), a fail-fast stop throws. upsert(id, body)
is PUT /:id and exists only on tables that mount it (guards: false +
generateId: false).
const result = await api.applications.batch.create(records, { failFast: true });
result.success; // Api.applications.Row[]
result.errors; // [{ index, record, error: { code, details: { fields } } }]
await api.applications.batch.update([{ id, stage: "offer" }]);
await api.applications.batch.delete([id1, id2]);
// Every page, following pagination.nextCursor (?starting_after=). When the
// cursor is null — sorting by a column masked for you — it pages by offset.
import { paginate } from "./quickback.client";
for await (const page of paginate(api.applications.list, { limit: 100 })) {
page.data;
}Bearer lane (React Native, native clients)
By default the client authenticates with the Better Auth session cookie
(credentials: "include"). React Native and other non-browser clients send
no Origin header, so every cookie-authenticated write is refused with
403 CSRF_ORIGIN_REJECTED — the CSRF guard only
admits cookie mutations from a trusted origin. Use the bearer lane instead:
import * as SecureStore from "expo-secure-store";
const api = createClient({
baseUrl: "https://api.example.com",
auth: "bearer", // no cookies; credentials default to "omit"
sessionStore: { // default: memory, lost on reload
get: () => SecureStore.getItemAsync("qb.session"),
set: (token) => (token
? SecureStore.setItemAsync("qb.session", token)
: SecureStore.deleteItemAsync("qb.session")),
},
});
// Sign in through the client: the session token arrives in set-auth-token
// on /auth/v1/* and is saved to sessionStore.
await api.request("POST", "/auth/v1/sign-in/email", { body: { email, password } });
await api.applications.list();Every request carries Authorization: Bearer <token>:
- the JWT the API returned last in
set-auth-token(kept in memory — withauth.jwtevery session-authenticated call returns one), else - the session token from
sessionStore.
set-auth-token means two things by path: under /auth/v1 it is Better
Auth's session token (saved to sessionStore); anywhere else it is a
short-lived JWT (cached in memory). When the API answers 401 to a JWT —
expired, key rotated, revoked —
the client drops it and retries the request once on the session token;
the session path re-validates and returns a fresh JWT. A 401 on the session
token itself is final. An authorization header you pass in headers wins
over the lane.
Realtime
With realtime enabled, api.realtime.subscribe(handler, { ticket? }) opens
the WebSocket for you: it mints a ws-ticket
through the same auth lane (a fresh one for every reconnect — tickets live 60
seconds), shares one socket between subscribers with the same ticket body,
reconnects two seconds after a drop, and closes when the last subscriber
unsubscribes. A ticket refused with 401, 403 or 429 (a session mid-refresh,
a rate limit) is retried with backoff from two seconds up to thirty; any
other 4xx is final. Frames are CloudEvents envelopes — switch on qbframe.
const unsubscribe = api.realtime.subscribe((frame) => {
if (frame.qbframe === "postgres_changes") refetch(frame.data.table);
});The client mirrors the type tree: api.<x> exists wherever Api.<x> is a
table with routes, and a method exists only if its route does — a table
without delete access has no delete. Record actions take the record id
first; standalone actions with path parameters take a params object; GET
actions send their input as query parameters. Actions declared
responseType: 'stream' or 'file' resolve to the raw Response.
List-shaped methods accept limit, offset, sort, search, total,
fields, include, startingAfter / endingBefore and filter — see
Query Parameters; array filter values are comma-joined for
.in, and an unknown filter field is a 400, not an ignored key.
Changeset (application/vnd.quickback.changeset+json) writes are not in the
client; send them with api.request.
Runtime extras for generic UI and native clients:
resources— keyed by schema-registry table name (the key ofSchemaRegistry.tables, the table's JS name), so UI holding a registry table can reach its routes without building a URL:{ client, table, ops, views, actions, recordActions, routes }.clientis the dotted path on the client ("applications","crm.contacts");recordActionsis the subset ofactionsthat take the record id;routesmapslist/get/create/update/delete/upsert,batchCreate/batchUpdate/batchDelete/batchUpsert,views.<name>andactions.<name>to{ method, path }(path templates with{id}) for clients that build requests themselves (Swift, Kotlin).api.schema()—GET /api/v1/schema, typed asSchemaRegistry(see Schema Registry). The registry is fetched, never embedded: it describes columns, roles and masking, so it stays behind that route's auth gate instead of shipping in every frontend bundle. The gate defaults to platform sysadmins; open it to your frontend's users withschemaRegistry.access.api.request(method, path, { query, body })— any other route (Better Auth, changesets, custom paths) through the same auth lane and error handling.
The emitted file is plain TypeScript that passes strict,
erasableSyntaxOnly, verbatimModuleSyntax and noUnused* — it drops into a
Vite / shadcn template as-is.
Configuration
The artifact is on by default:
export default defineConfig({
// Default: quickback.gen.ts
types: true,
});Use a custom path, fan out identical copies to colocated apps, disable
branding, or turn the artifact off. Paths resolve inside the project root:
.. segments and absolute paths fail the compile (Path traversal is not allowed). A sibling package consumes the file through a path alias
("@api/*": ["../api/client/*"] in its tsconfig.json), not by emitting
into it:
export default defineConfig({
types: {
output: [
"quickback.gen.ts",
"apps/web/src/lib/quickback.gen.ts",
],
brandIds: true,
},
});types value | Behavior |
|---|---|
true or omitted | Write quickback.gen.ts |
false | Do not emit the first-party type tree |
"src/lib/quickback.gen.ts" | Write to one custom path |
["quickback.gen.ts", "apps/web/src/lib/quickback.gen.ts"] | Fan out identical copies inside the project |
{ output, brandIds: false } | Configure paths and use structural IDs |
api-types.gen.ts remains available through openapi.types for openapi-fetch and third-party OpenAPI tools. The two artifacts are independent: disabling OpenAPI types does not disable the first-party tree.
The compiler also emits reports/types.parity.gen.ts when both artifacts exist. Quickback's output-matrix CI compiles that proof separately to require mutual assignability between direct table/action types and the OpenAPI wire types; it is outside the generated application's src/**/* build.