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 types
    • co.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() and z.max() are not yet enforced, we'll add validation in future releases
    • complex types like z.object() and z.array() to define JSON-like fields without internal collaboration
    • combinators like z.optional() and z.discriminatedUnion()
      • these also work on CoValue types!
    • see the updated Docs on Primitive Fields, Docs on Optional References and Docs on Unions of CoMaps

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>
  );
}