Skip to content
Built with

Type Safety ​

Build & Deploy

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 ​

Type pipeline: Drizzle schema flows to DB types, branching into route handlers (typed fetch), page loaders (component props), and page actions (useForm / action())
  1. Database types come from your Drizzle schema. Column types are known at compile time.
  2. Route handlers: return types and validator schemas become the generated RouteMap, which the typed fetch client consumes.
  3. Page loaders: return types flow into page components as props via InferProps.
  4. Page actions: validator schemas flow into useForm and action() 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.

ts
// 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();
});
ts
// 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.

ts
// pages/users/index.server.ts
export type Props = InferProps<typeof loader>;

export const loader = defineHandler(async (c) => {
  return { users: await db.select().from(users) };
});
vue
<!-- 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.

ts
// pages/users/create.server.ts
export const action = defineHandler.withValidator({
  body: insertUserSchema,
})(async (c, { body }) => {
  await db.insert(users).values(body);
});
ts
const form = useForm('/users/create', { name: '', email: '' });
//                    ^ autocompletes     ^ must match body schema

form.errors.email; // ✓ string | undefined
form.errors.foo; // ✗ TypeScript error

Named actions work the same way. The ?actionName suffix selects the right types for that action:

ts
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 validators

The action() helper gets the same type checking. See Actions & Forms for the full API.

What gets checked ​

LayerWhat's type-checked
DatabaseTable columns, value types, insert/update shapes from Drizzle schema
Handlersc.env bindings, validator input, return type
Fetch clientRoute paths, HTTP methods, body/query/params, response type
useFormAction URLs, data fields, errors keys, reset() args, params
action()Action URLs, data payload, params

Serialization ​

Client types follow JSON serialization through Serialize<T>:

Source typeSerialized type
string, number, booleanunchanged
Datestring
Responseexcluded (passed through at runtime)
bigint, Function, undefinedexcluded
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.

ts
// 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.

json
{
  "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.

Next steps ​

implemented.stream AI