Valibase
A Valibot Schema Generator For PocketBase
3 minutes read
Project Overview
Valibase is a lightweight Go package that connects to a PocketBase instance a automatically generates fully-typed Valibot schemas based on your database collections.
These schemas can then be used directly in “frontend” frameworks like SvelteKit to provide:
Runtime form validation
API payload validation
Strong TypeScript inference
Single-source-of-truth data models
The goal was simple: remove repetitive backend schema definitions and make database-driven apps safer and faster to build.
The Problem
PocketBase already provides a powerful admin UI and schema system, which makes spinning up a backend incredibly fast. However, when building production-ready frontends, I consistently ran into the same friction points:
Re-implementing validation logic in SvelteKit
Keeping frontend schemas in sync with PocketBase collections
Manually updating types after database changes
Writing repetitive form validation rules
While tools like pocketbase-typegen solve the TypeScript typing problem, they stop short of runtime validation. For user input, API routes, and form submission, static types alone aren’t enough, you still need robust schemas that can actually validate data at runtime.
That gap is what Valibase aims to fill.
The Solution
Valibase introspects a PocketBase instance and produces:
Valibot object schemas for each collection
Field-level validators based on PocketBase rules
Required/optional flags
Type-safe outputs ready for frontend use
For example, the generated input schema for the users collection could look like below:
export const userInput = v.object({
avatar: v.optional(
v.pipe(
fileSchema,
v.mimeType(
["image/jpeg", "image/png", "image/svg+xml", "image/gif", "image/webp"],
"Please select one of the following file types: JPEG or PNG or SVG+XML or GIF or WEBP",
),
),
),
name: v.optional(
v.pipe(
v.string(),
v.maxLength(255, "Input must be at most 255 characters"),
),
),
username: v.pipe(
v.string(),
v.minLength(3, "Input must be at least 3 characters"),
v.maxLength(80, "Input must be at most 80 characters"),
v.regex(/^[\w][\w\.\-]*$/, "Invalid format"),
),
tasks: v.optional(
v.pipe(v.array(v.string()), v.brand("RelationMultiple")),
[],
),
languages: v.optional(stringEnum("Swedish", "English", "Spanish")),
address: v.optional(geoPointSchema),
email: emailSchema,
emailVisibility: v.optional(v.boolean()),
verified: v.optional(v.boolean()),
});This example provides you with min/max length, regex/patterns, picklists and so on.
For retrieving users, the schema excludes the specific validations because usually, the database would get updated at some point. name instead becomes:
export const userResponse = v.object({
...
// PocketBase returns undefined fields as "" so we have this helper schema
name: optionalTextResponse(v.string()),
...
});Further, when querying the database, you get type inference for free:
import PocketBase from 'pocketbase';
import { Collections, type TypedPocketBase } from '$lib/database/database.ts';
const pb = new PocketBase('http://localhost:8090') as TypedPocketBase;
// returns User[]
const users = await pb.collection(Collections.Users).getFullList()Because Valibot is both lightweight and strongly typed, the generated schemas integrate cleanly into modern TypeScript apps without adding unnecessary overhead.
Challenges & Design Decisions
One of the main challenges was mapping PocketBase’s schema system to Valibot’s API in a way that stayed flexible but predictable.
PocketBase supports multiple field types and constraints, each of which needs to be translated into the appropriate Valibot validators. The generator also has to product code that is:
Human-readble
Easy to extend
Safe to commit to version control
Deterministic between runs
I focused on keeping the output minimal and idiomatic rather than auto-generating overly complex logic.
One design decision that was made was to exclude validation of expanded fields.
For example, if the users collection has a relation tasks which is a reference to the collection todos, the PocketBase response would look like this:
{
name: 'Hannes Sjölander',
tasks: ['id_1', 'id_2'],
expand: {
tasks: [
{
id: 'id_1',
name: 'Clean the kitchen',
done: true
},
{
id: 'id_2',
name: 'Write this case study',
done: false
}
]
}
}In this example, the expand field would not be validated by Valibot, only the outer tasks entry. Validating the relation fields would either cause circular dependencies in more complex designs of alternatively, increase the file size by reconstructing each expanded type.
All though, you still get full IntelliSense for the TypeScript types:
(alias) type User = {
name: string | undefined;
avatar: (string & Brand<"FileName">) | undefined;
id: string & Brand<"RecordId">;
...
expand: {
tasks: Todo[] | undefined
} | undefined
}
import UserUsage
To use Valibot, please refer to the README.md