[!CAUTION] This library is under active development and the API is rapidly changing. Do not use in production.
Breaking changes are expected between any release. The library is currently in an experimental phase as we work towards a stable v1.0 release.
A TypeScript-first schema declaration and validation library for full-stack applications. Define your database schema once and get type-safe schemas for your database, client, and validation layers with automatic transformations.
npm install cogsbox-shape
# or
yarn add cogsbox-shape
# or
pnpm add cogsbox-shape
In full-stack applications, data flows through multiple layers:
- Database stores data in SQL types (integers, varchars, etc.)
- Server needs to transform data for clients (e.g., convert cents to dollars)
- Client expects data in specific formats (e.g., temporary UUIDs for new records)
- Forms need validation rules and default values
Traditional approaches require defining these transformations in multiple places, leading to type mismatches and runtime errors.
cogsbox-shape introduces a unified flow that mirrors how data moves through your application:
Initial State
\
SQL ←→ Transform ←→ Client ←→ Validation
This flow ensures type safety at every step while giving you control over transformations.
Start with your database reality:
import { s, schema } from "cogsbox-shape";
const userSchema = schema({
_tableName: "users",
id: s.sql({ type: "int", pk: true }), // In DB: integer auto-increment
email: s.sql({ type: "varchar", length: 255 }),
createdAt: s.sql({ type: "datetime", default: "CURRENT_TIMESTAMP" }),
});
When creating new records, you often need different types than what's stored in the database:
const userSchema = schema({
_tableName: "users",
id: s.sql({ type: "int", pk: true }).initialState(() => crypto.randomUUID()),
// DB stores integers, but new records start with UUID strings
// This automatically creates a union type: number | string on the client
});
Transform how data appears to clients:
const productSchema = schema({
_tableName: "products",
id: s.sql({ type: "int", pk: true }).initialState(() => `tmp_${Date.now()}`),
price: s
.sql({ type: "int" }) // Stored as cents in DB
.client(() => z.number().multipleOf(0.01)) // But dollars on client
.transform({
toClient: (cents) => cents / 100,
toDb: (dollars) => Math.round(dollars * 100),
}),
});
Add validation that runs at your client -> server boundary:
const userSchema = schema({
_tableName: "users",
email: s
.sql({ type: "varchar", length: 255 })
.validation(({ sql }) => sql.email().toLowerCase()),
age: s.sql({ type: "int" }).validation(({ sql }) => sql.min(18).max(120)),
});
Define bidirectional transformations between database and client:
const userSchema = schema({
_tableName: "users",
status: s
.sql({ type: "int" }) // 0 or 1 in database
.client(() => z.enum(["active", "inactive"])) // String enum on client
.transform({
toClient: (dbValue) => (dbValue === 1 ? "active" : "inactive"),
toDb: (clientValue) => (clientValue === "active" ? 1 : 0),
}),
});
Define relationships between schemas and create specific data views using the createSchemaBox
.
import { s, schema, createSchemaBox } from "cogsbox-shape";
// Define schemas with relationship placeholders
const users = schema({
_tableName: "users",
id: s.sql({ type: "int", pk: true }),
name: s.sql({ type: "varchar" }),
posts: s.hasMany(), // Placeholder for a one-to-many relationship
});
const posts = schema({
_tableName: "posts",
id: s.sql({ type: "int", pk: true }),
title: s.sql({ type: "varchar" }),
authorId: s.reference(() => users.id), // Foreign key reference
});
The createSchemaBox
function processes your raw schemas, resolves the relationships, and gives you a powerful, type-safe API for accessing them.
const box = createSchemaBox({ users, posts }, (s) => ({
users: {
// Resolve the 'posts' relation on the 'users' schema
posts: { fromKey: "id", toKey: s.posts.authorId },
},
}));
Once the box is created, you can access the base schemas (without relations) and their default values.
// Access the processed schemas for the 'users' table
const userSchemas = box.users.schemas;
const userDefaults = box.users.defaults;
// Type-safe operations
const newUser = userDefaults; // { id: 0, name: '' }
// The base schema does NOT include the 'posts' relation
type UserClient = z.infer<typeof userSchemas.client>; // { id: number; name: string; }
The real power is in creating views to select exactly which relationships to include for a given operation.
// Create a view that includes the 'posts' for a user
const userWithPostsView = box.users.createView({
posts: true, // Select the 'posts' relation
});
// The type of this view now includes the nested posts
type UserWithPosts = z.infer<typeof userWithPostsView.client>;
// {
// id: number;
// name: string;
// posts: {
// id: number;
// title: string;
// authorId: number;
// }[];
// }
// You can also get default values for the view
const newUserWithPosts = userWithPostsView.defaults;
// { id: 0, name: '', posts: [] }
Here's a complete example showing the power of the flow:
import { s, schema, createSchemaBox, z } from "cogsbox-shape";
const users = schema({
_tableName: "users",
id: s
.sql({ type: "int", pk: true })
.initialState(() => `user_${crypto.randomUUID()}`),
email: s
.sql({ type: "varchar", length: 255 })
.validation(({ sql }) => sql.email()),
metadata: s
.sql({ type: "text" })
.client(
z.object({
preferences: z.object({
theme: z.enum(["light", "dark"]),
notifications: z.boolean(),
}),
})
)
.transform({
toClient: (json) => JSON.parse(json),
toDb: (obj) => JSON.stringify(obj),
}),
posts: s.hasMany({ defaultCount: 0 }), // Default to an empty array
});
const posts = schema({
_tableName: "posts",
id: s.sql({ type: "int", pk: true }),
title: s.sql({ type: "varchar" }),
published: s
.sql({ type: "int" }) // 0 or 1 in DB
.client(() => z.boolean())
.transform({
toClient: (int) => Boolean(int),
toDb: (bool) => (bool ? 1 : 0),
}),
authorId: s.reference(() => users.id),
});
const box = createSchemaBox({ users, posts }, (s) => ({
users: {
posts: { fromKey: "id", toKey: s.posts.authorId },
},
}));
// Use a view for our API response
const userApiView = box.users.createView({ posts: true });
// Use the schemas from the view
const { clientSchema, validationSchema, defaults, toClient, toDb } =
userApiView;
type UserApiResponse = z.infer<typeof clientSchema>;
// {
// id: string | number;
// email: string;
// metadata: { preferences: { theme: 'light' | 'dark'; notifications: boolean; } };
// posts: { id: number; title: string; published: boolean; authorId: number | string; }[];
// }
// Create a new user with view-aware defaults
const newUser = defaults;
// newUser.posts is now guaranteed to be an empty array.
// Validate user input against the view's validation schema
const validated = validationSchema.parse(userInput);
// Transform for database
const dbUser = toDb(validated);
// Transform for API response
const apiUser = toClient(dbUser);
- Type Safety: Full TypeScript support with inferred types at every layer.
- Single Source of Truth: Define your schema once, use it everywhere.
- Explicit Data Loading: Views encourage explicitly defining the data shape you need, preventing over-fetching.
- Transformation Co-location: Keep data transformations next to field definitions.
- Progressive Enhancement: Start simple, add complexity as needed.
- Framework Agnostic: Works with any TypeScript project.
-
s.sql(config)
: Define SQL column type. -
.initialState(value)
: Set default value for new records. -
.client(schema)
: Define client-side schema. -
.validation(schema)
: Add validation rules. -
.transform(transforms)
: Define bidirectional transformations.
-
s.reference(getter)
: Create a foreign key reference. -
s.hasMany(config)
: Define one-to-many relationship placeholder. -
s.hasOne()
: Define one-to-one relationship placeholder. -
s.manyToMany(config)
: Define many-to-many relationship placeholder.
-
schema(definition)
: Create a schema definition. -
createSchemaBox(schemas, resolver)
: The main function to create and resolve a schema registry. - From the box entry (e.g.,
box.users
):-
.schemas
: Access base Zod schemas (sql, client, validation). -
.defaults
: Access base default values. -
.transforms
: AccesstoClient
andtoDb
functions for the base schema. -
.createView(selection)
: Creates a new set of schemas and transforms including the selected relations.
-
MIT