Jazz 0.14.0 Introducing Zod-based schemas
We're excited to move from our own schema syntax to using Zod v4.
This is the first step in a series of releases to make Jazz more familiar and to make CoValues look more like regular data structures.
Overview:
So far, Jazz has relied on our own idiosyncratic schema definition syntax where you had to extend classes and be careful to use co.ref
for references.
// BEFORE import { co, CoMap, CoList, CoPlainText, ImageDefinition } from "jazz-tools"; export class Message extends CoMap { text = co.ref(CoPlainText); image = co.optional.ref(ImageDefinition); important = co.boolean; } export class Chat extends CoList.Of(co.ref(Message)) {}
While this had certain ergonomic benefits it relied on unclean hacks to work.
In addition, many of our adopters expressed a preference for avoiding class syntax, and LLMs consistently expected to be able to use Zod.
For this reason, we completely overhauled how you define and use CoValue schemas:
// AFTER import { co, z } from "jazz-tools"; export const Message = co.map({ text: co.plainText(), image: z.optional(co.image()), important: z.boolean(), }); export const Chat = co.list(Message);
Major breaking changes
Schema definitions
You now define CoValue schemas using two new exports from jazz-tools
:
- a new
co
definer that mirrors Zod's object/record/array syntax to define CoValue typesco.map()
,co.record()
,co.list()
,co.feed()
co.account()
,co.profile()
co.plainText()
,co.richText()
,co.fileStream()
,co.image()
- see the updated Defining CoValue Schemas
z
re-exported from Zod v4- primitives like
z.string()
,z.number()
,z.literal()
- note: additional constraints like
z.min()
andz.max()
are not yet enforced, we'll add validation in future releases
- note: additional constraints like
- complex types like
z.object()
andz.array()
to define JSON-like fields without internal collaboration - combinators like
z.optional()
andz.discriminatedUnion()
- these also work on CoValue types!
- see the updated Docs on Primitive Fields, Docs on Optional References and Docs on Unions of CoMaps
- primitives like
Similar to Zod v4's new object syntax, recursive and mutually recursive types are now much easier to express.
How to pass loaded CoValues
We've removed the useCoState
, useAccount
and useAccountOrGuest
hooks.
You should now use the CoState
and AccountCoState
reactive classes instead. These provide greater stability and are significantly easier to work with.
Calls to new CoState()
work just the same, but they return a slightly different type than before.
And while you can still read from the type just as before...
// @filename: schema.ts import { z, co } from "jazz-tools"; const Pet = co.map({ name: z.string(), age: z.number(), }); type Pet = co.loaded<typeof Pet>; const Person = co.map({ name: z.string(), age: z.number(), pets: co.list(Pet), }); type Person = co.loaded<typeof Person>;
// @filename: app.svelte <script lang="ts"> import { CoState } from "jazz-svelte"; import { Person } from "./schema"; const person = new CoState(Person, id); </script> <div> {person.current?.name} </div>
co.loaded
can also take a second argument to specify the loading depth of the expected CoValue, mirroring the resolve
options for CoState
, load
, subscribe
, etc.
<script lang="ts" module> export type Props = { person: co.loaded<typeof Person, { pets: { $each: true } }>; }; </script> <script lang="ts"> import { Person } from './schema'; let props: Props = $props(); </script> <div> {props.person.name} </div> <ul> {#each props.person.pets as pet} <li>{pet.name}</li> {/each} </ul>
Removing AccountSchema registration
We have removed the Typescript AccountSchema registration.
It was causing some deal of confusion to new adopters so we have decided to replace the magic inference with a more explicit approach.
You still need to pass your custom AccountSchema to your provider!
<!-- src/routes/+layout.svelte --> <script lang="ts" module> declare module 'jazz-svelte' { interface Register { Account: MyAppAccount; } } </script> // [!code --] <script lang="ts"> import { JazzProvider } from "jazz-svelte"; import { MyAppAccount } from "$lib/schema"; let { children } = $props(); </script> <JazzProvider sync={{ peer: "wss://cloud.jazz.tools/?key=your-api-key", when: "always" // When to sync: "always", "never", or "signedUp" }} AccountSchema={MyAppAccount} > {@render children()} </JazzProvider>
When using AccountCoState
you should now pass the Account
schema directly:
<script lang="ts"> import { AccountCoState } from "jazz-svelte"; import { MyAccount } from "./schema"; const account = new AccountCoState(MyAccount, { resolve: { profile: true, }, }); </script> <div> {account.current?.profile.name} </div>
Defining migrations
Now account schemas need to be defined with co.account()
and migrations can be declared using withMigration()
:
import { co, z, Group } from "jazz-tools"; const Pet = co.map({ name: z.string(), age: z.number(), }); const MyAppRoot = co.map({ pets: co.list(Pet), }); const MyAppProfile = co.profile({ name: z.string(), age: z.number().optional(), }); export const MyAppAccount = co.account({ root: MyAppRoot, profile: MyAppProfile, }).withMigration((account, creationProps?: { name: string }) => { if (account.root === undefined) { account.root = MyAppRoot.create({ pets: co.list(Pet).create([]), }); } if (account.profile === undefined) { const profileGroup = Group.create(); profileGroup.addMember("everyone", "reader"); account.profile = MyAppProfile.create({ name: creationProps?.name ?? "New user", }, profileGroup); } });
Defining Schema helper methods
You can no longer define helper methods directly within your schema, create standalone functions instead. See Docs on Helper methods for an example.
Minor breaking changes
_refs
and _edits
are now potentially null
The type of _refs
and _edits
is now nullable.
const Person = co.map({ name: z.string(), age: z.number(), }); const person = Person.create({ name: "John", age: 30 }); person._refs; // now nullable person._edits; // now nullable
members
and by
now return basic Account
We have removed the Account schema registration, so now members
and by
methods now always return basic Account
.
This means that you now need to rely on useCoState
on them to load their using your account schema.
function GroupMembers({ group }: { group: Group }) { const members = group.members; return ( <div> {members.map((member) => ( <GroupMemberDetails accountId={member.account.id} key={member.account.id} /> ))} </div> ); } function GroupMemberDetails({ accountId }: { accountId: string }) { const account = useCoState(MyAppAccount, accountId, { resolve: { profile: true, root: { pets: { $each: true }, }, }, }); return ( <div> <div>{account?.profile.name}</div> <ul>{account?.root.pets.map((pet) => <li>{pet.name}</li>)}</ul> </div> ); }