Type Safety
The SDK infers types from your database schema, route handlers and page loaders. Change a field and TypeScript shows every query, request and component that needs updating.
On this pageSee how types flow through your app · Type database queries and routes · Catch breaking changes early
The type pipeline
- Database types come from your Drizzle schema. Column types are known at compile time.
- Route handlers: return types and validator schemas become the generated
RouteMap, which the typed fetch client consumes. - Page loaders: return types flow into page components as props via
InferProps. - Page actions: validator schemas flow into
useFormandaction()for typed data, errors, and URL autocomplete.
Generated types live in .void/. They update when source files or the schema change.
Database → handler → client
This example shows types flowing from the Drizzle schema, through a handler, to the client. No manual annotations are needed.
// routes/api/users.ts
export const GET = defineHandler(async (c) => {
return db.select({ name: users.name, email: users.email }).from(users);
// ↑ return type inferred from schema columns
});
export const POST = defineHandler.withValidator({
body: insertUserSchema, // ← derived from Drizzle schema
})(async (c, { body }) => {
return db.insert(users).values(body).returning();
});// Client: types inferred from handlers above
const users = await fetch('/api/users');
// ↑ { name: string; email: string }[]
await fetch('/api/users', {
method: 'POST',
body: { name: 'Alice', email: 'alice@example.com' }, // ← typed from validator
});One Drizzle schema defines the columns, runtime validation, handler inputs and client checks. For endpoints without a table, use any Standard Schema library.
Loader → page props
This example passes a loader's return type to the page component with InferProps, in pages mode.
// pages/users/index.server.ts
export type Props = InferProps<typeof loader>;
export const loader = defineHandler(async (c) => {
return { users: await db.select().from(users) };
});<!-- pages/users/index.vue -->
<script setup lang="ts">
import type { Props } from './index.server';
defineProps<Props>(); // ← { users: { id: number; name: string; ... }[] }
</script>No manual interface is needed. Props always match what the loader returns.
Action → useForm
This example types a form from an action. With withValidator(), the body schema flows to useForm. You get typed data, typed errors keys and URL autocomplete.
// pages/users/create.server.ts
export const action = defineHandler.withValidator({
body: insertUserSchema,
})(async (c, { body }) => {
await db.insert(users).values(body);
});const form = useForm('/users/create', { name: '', email: '' });
// ^ autocompletes ^ must match body schema
form.errors.email; // ✓ string | undefined
form.errors.foo; // ✗ TypeScript errorNamed actions work the same way. The ?actionName suffix selects the right types for that action:
const updateForm = useForm('/users/:id?update', { name: '' }, { params: { id } });
const deleteForm = useForm('/users/:id?delete', { id: '' }, { params: { id } });
// Each form's data and errors are typed from their respective validatorsThe action() helper gets the same type checking. See Actions & Forms for the full API.
What gets checked
| Layer | What's type-checked |
|---|---|
| Database | Table columns, value types, insert/update shapes from Drizzle schema |
| Handlers | c.env bindings, validator input, return type |
| Fetch client | Route paths, HTTP methods, body/query/params, response type |
useForm | Action URLs, data fields, errors keys, reset() args, params |
action() | Action URLs, data payload, params |
Serialization
Client types follow JSON serialization through Serialize<T>:
| Source type | Serialized type |
|---|---|
string, number, boolean | unchanged |
Date | string |
Response | excluded (passed through at runtime) |
bigint, Function, undefined | excluded |
Array<T> / { key: T } | recursively serialized (function-valued keys stripped) |
Context variables
This step shares typed data between middleware and handlers. Augment CloudContextVariables to add your own context variables.
// middleware/01.auth.ts
declare module 'void' {
interface CloudContextVariables {
requestId: string;
}
}
export default defineMiddleware(async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
});c.get("requestId") now returns string everywhere. No assertion is needed. Multiple augmentations across files are merged together.
Setup
This step extends the generated tsconfig in your project.
{
"extends": "./.void/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"types": ["void/env"]
},
"include": ["src", "routes", "middleware", "crons", "queues"]
}Already have a TypeScript config? Run void init --tsconfig. It adds the SDK types and keeps your settings.
In CI or after a fresh clone, run void prepare. Otherwise vite dev and vite build generate the .void/ files.