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
Calls to useCoState()
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...
import { z, co } from "jazz-tools"; import { useCoState } from "jazz-react"; 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>; function MyComponent({ id }: { id: string }) { const person = useCoState(Person, id); return person && <PersonName person={person} />; } function PersonName({ person }: { person: Person }) { return <div>{person.name}</div>; }
co.loaded
can also take a second argument to specify the loading depth of the expected CoValue, mirroring the resolve
options for useCoState
, load
, subscribe
, etc.
import { z, co } from "jazz-tools"; import { useCoState } from "jazz-react"; 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>; function MyComponent({ id }: { id: string }) { const personWithPets = useCoState(Person, id, { resolve: { pets: { $each: true } } }); return personWithPets && <PersonAndFirstPetName person={personWithPets} />; } function PersonAndFirstPetName({ person }: { person: co.loaded<typeof Person, { pets: { $each: true } }> }) { return <div>{person.name} & {person.pets[0].name}</div>; }
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.
import { JazzProvider } from "jazz-react"; import { MyAccount } from "./schema"; declare module "jazz-react" { interface Register { Account: MyAccount; } } export function MyApp({ children }: { children: React.ReactNode }) { return ( <JazzProvider sync={{ peer: "wss://cloud.jazz.tools/?key=your-api-key", }} AccountSchema={MyAccount} > {children} </JazzProvider> ); }
When using useAccount
you should now pass the Account
schema directly:
import { useAccount } from "jazz-react"; import { MyAccount } from "./schema"; function MyComponent() { const { me } = useAccount(MyAccount, { resolve: { profile: true, }, }); return <div>{me?.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> ); }