CoMaps

CoMaps are key-value objects that work like JavaScript objects. You can access properties with dot notation and define typed fields that provide TypeScript safety. They're ideal for structured data that needs type validation.

Creating CoMaps

CoMaps are typically defined with co.map() and specifying primitive fields using z (see Defining schemas: CoValues for more details on primitive fields):

import { co, z } from "jazz-tools";

const Project = co.map({
  name: z.string(),
  startDate: z.date(),
  status: z.literal(["planning", "active", "completed"]),
  coordinator: co.optional(Member),
});
export type Project = co.loaded<typeof Project>;
export type ProjectInitShape = co.input<typeof Project>; // type accepted by `Project.create`

You can create either struct-like CoMaps with fixed fields (as above) or record-like CoMaps for key-value pairs:

const Inventory = co.record(z.string(), z.number());

To instantiate a CoMap:

const project = Project.create({
  name: "Spring Planting",
  startDate: new Date("2025-03-15"),
  status: "planning",
});

const inventory = Inventory.create({
  tomatoes: 48,
  basil: 12,
});

Ownership

When creating CoMaps, you can specify ownership to control access:

// Create with default owner (current user)
const privateProject = Project.create({
  name: "My Herb Garden",
  startDate: new Date("2025-04-01"),
  status: "planning",
});

// Create with shared ownership
const gardenGroup = Group.create();
gardenGroup.addMember(memberAccount, "writer");

const communityProject = Project.create(
  {
    name: "Community Vegetable Plot",
    startDate: new Date("2025-03-20"),
    status: "planning",
  },
  { owner: gardenGroup },
);

See Groups as permission scopes for more information on how to use groups to control access to CoMaps.

Reading from CoMaps

CoMaps can be accessed using familiar JavaScript object notation:

console.log(project.name); // "Spring Planting"
console.log(project.status); // "planning"

Handling Optional Fields

Optional fields require checks before access:

if (project.coordinator) {
  console.log(project.coordinator.name); // Safe access
}

Recursive references

You can wrap references in getters. This allows you to defer evaluation until the property is accessed. This technique is particularly useful for defining circular references, including recursive (self-referencing) schemas, or mutually recursive schemas.

import { co, z } from "jazz-tools";

const Project = co.map({
  name: z.string(),
  startDate: z.date(),
  status: z.literal(["planning", "active", "completed"]),
  coordinator: co.optional(Member),
  get subProject() {
    return Project.optional();
  },
});

export type Project = co.loaded<typeof Project>;

When the recursive references involve more complex types, it is sometimes required to specify the getter return type:

const ProjectWithTypedGetter = co.map({
  name: z.string(),
  startDate: z.date(),
  status: z.literal(["planning", "active", "completed"]),
  coordinator: co.optional(Member),
  get subProjects(): co.Optional<co.List<typeof Project>> {
    return co.optional(co.list(Project));
  },
});

export type Project = co.loaded<typeof Project>;

Partial

For convenience Jazz provies a dedicated API for making all the properties of a CoMap optional:

const Project = co.map({
  name: z.string(),
  startDate: z.date(),
  status: z.literal(["planning", "active", "completed"]),
});

const ProjectDraft = Project.partial();

// The fields are all optional now
const project = ProjectDraft.create({});

Pick

You can also pick specific fields from a CoMap:

const Project = co.map({
  name: z.string(),
  startDate: z.date(),
  status: z.literal(["planning", "active", "completed"]),
});

const ProjectStep1 = Project.pick({
  name: true,
  startDate: true,
});

// We don't provide the status field
const project = ProjectStep1.create({
  name: "My project",
  startDate: new Date("2025-04-01"),
});

Working with Record CoMaps

For record-type CoMaps, you can access values using bracket notation:

const inventory = Inventory.create({
  tomatoes: 48,
  peppers: 24,
  basil: 12,
});

console.log(inventory["tomatoes"]); // 48

Updating CoMaps

To update a CoMap's properties, use the $jazz.set method:

project.$jazz.set("name", "Spring Vegetable Garden"); // Update name
project.$jazz.set("startDate", new Date("2025-03-20")); // Update date

The $jazz namespace is available on all CoValues, and provides access to methods to modify and load CoValues, as well as access common properties like id and owner.

When updating references to other CoValues, you can provide both the new CoValue or a JSON object from which the new CoValue will be created.

const Dog = co.map({
  name: co.plainText(),
});
const Person = co.map({
  name: co.plainText(),
  dog: Dog,
});

const person = Person.create({
  name: "John",
  dog: { name: "Rex" },
});

// Update the dog field using a CoValue
person.$jazz.set("dog", Dog.create({ name: co.plainText().create("Fido") }));
// Or use a plain JSON object
person.$jazz.set("dog", { name: "Fido" });

When providing a JSON object, Jazz will automatically create the CoValues for you. To learn more about how permissions work in this case, refer to Ownership on implicit CoValue creation.

Type Safety

CoMaps are fully typed in TypeScript, giving you autocomplete and error checking:

project.$jazz.set("name", "Spring Vegetable Planting"); // ✓ Valid string
project.$jazz.set("startDate", "2025-03-15"); // ✗ Type error: expected Date
// Argument of type 'string' is not assignable to parameter of type 'Date'

Soft Deletion

Implementing a soft deletion pattern by using a deleted flag allows you to maintain data for potential recovery and auditing.

const Project = co.map({
  name: z.string(),
  deleted: z.optional(z.boolean()),
});

When an object needs to be "deleted", instead of removing it from the system, the deleted flag is set to true. This gives us a property to omit it in the future.

Deleting Properties

You can delete properties from CoMaps:

inventory.$jazz.delete("basil"); // Remove a key-value pair

// For optional fields in struct-like CoMaps
project.$jazz.set("coordinator", undefined); // Remove the reference

Running migrations on CoMaps

Migrations are functions that run when a CoMap is loaded, allowing you to update existing data to match new schema versions. Use them when you need to modify the structure of CoMaps that already exist in your app. Unlike Account migrations, CoMap migrations are not run when a CoMap is created.

Note: Migrations are run synchronously and cannot be run asynchronously.

Here's an example of a migration that adds the priority field to the Task CoMap:

const Task = co
  .map({
    done: z.boolean(),
    text: co.plainText(),
    version: z.literal([1, 2]),
    priority: z.enum(["low", "medium", "high"]), // new field
  })
  .withMigration((task) => {
    if (task.version === 1) {
      task.$jazz.set("priority", "medium");
      // Upgrade the version so the migration won't run again
      task.$jazz.set("version", 2);
    }
  });

Migration best practices

Design your schema changes to be compatible with existing data:

  • Add, don't change: Only add new fields; avoid renaming or changing types of existing fields
  • Make new fields optional: This prevents errors when loading older data
  • Use version fields: Track schema versions to run migrations only when needed

Migration & reader permissions

Migrations need write access to modify CoMaps. If some users only have read permissions, they can't run migrations on those CoMaps.

Forward-compatible schemas (where new fields are optional) handle this gracefully - users can still use the app even if migrations haven't run.

Non-compatible changes require handling both schema versions in your app code using discriminated unions.

When you can't guarantee all users can run migrations, handle multiple schema versions explicitly:

const TaskV1 = co.map({
  version: z.literal(1),
  done: z.boolean(),
  text: z.string(),
});

const TaskV2 = co
  .map({
    // We need to be more strict about the version to make the
    // discriminated union work
    version: z.literal(2),
    done: z.boolean(),
    text: z.string(),
    priority: z.enum(["low", "medium", "high"]),
  })
  .withMigration((task) => {
    if (task.version === 1) {
      task.$jazz.set("version", 2);
      task.$jazz.set("priority", "medium");
    }
  });

// Export the discriminated union; because some users might
// not be able to run the migration
export const Task = co.discriminatedUnion("version", [TaskV1, TaskV2]);
export type Task = co.loaded<typeof Task>;

Best Practices

Structuring Data

  • Use struct-like CoMaps for entities with fixed, known properties
  • Use record-like CoMaps for dynamic key-value collections
  • Group related properties into nested CoMaps for better organization

Common Patterns

Helper methods

You should define helper methods of CoValue schemas separately, in standalone functions:

import { co, z } from "jazz-tools";

const Project = co.map({
  name: z.string(),
  startDate: z.date(),
  endDate: z.optional(z.date()),
});
type Project = co.loaded<typeof Project>;

export function isProjectActive(project: Project) {
  const now = new Date();
  return (
    now >= project.startDate && (!project.endDate || now <= project.endDate)
  );
}

export function formatProjectDuration(
  project: Project,
  format: "short" | "full",
) {
  const start = project.startDate.toLocaleDateString();
  if (!project.endDate) {
    return format === "full" ? `Started on ${start}, ongoing` : `From ${start}`;
  }

  const end = project.endDate.toLocaleDateString();
  return format === "full"
    ? `From ${start} to ${end}`
    : `${(project.endDate.getTime() - project.startDate.getTime()) / 86400000} days`;
}

const project = Project.create({
  name: "My project",
  startDate: new Date("2025-04-01"),
  endDate: new Date("2025-04-04"),
});

console.log(isProjectActive(project)); // false
console.log(formatProjectDuration(project, "short")); // "3 days"

Uniqueness

CoMaps are typically created with a CoValue ID that acts as an opaque UUID, by which you can then load them. However, there are situations where it is preferable to load CoMaps using a custom identifier:

  • The CoMaps have user-generated identifiers, such as a slug
  • The CoMaps have identifiers referring to equivalent data in an external system
  • The CoMaps have human-readable & application-specific identifiers
    • If an application has CoValues used by every user, referring to it by a unique well-known name (eg, "my-global-comap") can be more convenient than using a CoValue ID

Consider a scenario where one wants to identify a CoMap using some unique identifier that isn't the Jazz CoValue ID:

// This will not work as `learning-jazz` is not a CoValue ID
const myTask = await Task.load("learning-jazz");

To make it possible to use human-readable identifiers Jazz lets you to define a unique property on CoMaps.

Then the CoValue ID is deterministically derived from the unique property and the owner of the CoMap.

// Given the project owner, myTask will have always the same id
Task.create(
  {
    text: "Let's learn some Jazz!",
  },
  {
    unique: "learning-jazz",
    owner: project.$jazz.owner, // Different owner, different id
  },
);

Now you can use CoMap.loadUnique to easily load the CoMap using the human-readable identifier:

const learnJazzTask = await Task.loadUnique(
  "learning-jazz",
  project.$jazz.owner.$jazz.id,
);

It's also possible to combine the create+load operation using CoMap.upsertUnique:

await Task.upsertUnique({
  value: {
    text: "Let's learn some Jazz!",
  },
  unique: "learning-jazz",
  owner: project.$jazz.owner,
});

Caveats:

  • The unique parameter acts as an immutable identifier - i.e. the same unique parameter in the same Group will always refer to the same CoValue.

    • To make dynamic renaming possible, you can create an indirection where a stable CoMap identified by a specific value of unique is simply a pointer to another CoMap with a normal, dynamic CoValue ID. This pointer can then be updated as desired by users with the corresponding permissions.
  • This way of introducing identifiers allows for very fast lookup of individual CoMaps by identifier, but it doesn't let you enumerate all the CoMaps identified this way within a Group. If you also need enumeration, consider using a global co.record() that maps from identifier to a CoMap, which you then do lookups in (this requires at least a shallow load of the entire co.record(), but this should be fast for up to 10s of 1000s of entries)