Typing & Schemas

Infer TypeScript types from entity definitions and type resolved relations.

Every entity constructor returns a .schema property — a Zod object built from the entity's fields. You can use it for runtime validation or to infer TypeScript types, so your entity definitions remain the single source of truth.

Inferring entity types

Use the InferEntityData utility (or z.infer on an entity's .schema) to get a full TypeScript type:


          import type { z } from "zod";
import { InferEntityData } from "@cms/types";
import { page } from "./cms.config";

type Page = InferEntityData<typeof page>;
// or
type Page = z.infer<typeof page.schema>;
// {
//   id: string;
//   createdAt: string;
//   updatedAt: string;
//   slug: string | null | undefined;
//   status: "draft" | "published";
//   blocks: ({ _type: "hero"; ... } | { _type: "faq"; ... })[] | null | undefined;
//   ...
// }
        

The inferred type reflects the exact shape of each field:

  • Required fields are non-nullable (title: string)
  • Optional fields are nullable and optional (description: string | null | undefined)
  • Auto fields are always included (id, createdAt, updatedAt, plus locale and translationGroup for translatable entities)
  • Select fields produce literal unions ("draft" | "published")
  • Object fields produce nested object types
  • Union fields produce discriminated unions with a _type discriminator
  • Relation fields produce { id: string; _entity: "<target>" } — where _entity is a literal matching the target entity name

Relation type shape

All relations — single-target, multiple, and polymorphic — use the same { id, _entity } shape:


          const posts = entity("posts", {
  fields: [
    relation("author", { to: authors, required: true }),
    relation("tags", { to: tags, multiple: true }),
    relation("contributor", { to: [staff, guest] }),
  ],
});

type Post = InferEntityData<typeof posts>;
// Post["author"]      → { id: string; _entity: "authors" }
// Post["tags"]        → { id: string; _entity: "tags" }[] | null | undefined
// Post["contributor"] → { id: string; _entity: "staff" | "guest" } | null | undefined
        

The _entity type is always a literal — the exact target entity name, not a wide string. For polymorphic relations, it's a union of the target names. This lets you discriminate at the type level:


          if (post.contributor._entity === "staff") {
  // TypeScript knows this is the staff branch
}
        

What the schema contains

The schema is built by iterating over all fields (layout directives like tabs, row, and collapsible are flattened away — only actual fields end up in the schema).

For each field:

  • If required: true → the field's Zod schema is used as-is
  • If not required → wrapped in .optional().nullable()

          const post = entity("posts", {
  fields: [text("title", { required: true }), text("description")],
});

// post.schema is equivalent to:
// z.object({
//   id: z.string(),
//   createdAt: z.iso.datetime(),
//   updatedAt: z.iso.datetime(),
//   title: z.string(),
//   description: z.string().optional().nullable(),
// })
        

Shared field factories

A shared field factory — a helper that wraps one or more built-in fields so you can reuse it across entities — has to preserve the literal field name in its type signature for inference to work correctly. The pattern is a generic parameter constrained to string:


          // ✅ Correct — generic over the name
const link = <TName extends string>(name: TName) =>
  union(name, { of: [external, internal] });

// ❌ Wrong — `name: string` widens the literal away
const link = (name: string) => union(name, { of: [external, internal] });
        

The shape of an object() is inferred from the names of the fields it contains. When the name widens to string, that shape collapses to Record<string, ...> — and Record<string, X> is a single type, so every other field declared alongside the widened one disappears from the inferred entity type.


          const link = (name: string) => union(name, { of: [external, internal] }); // bug

const hero = object("hero", {
  fields: [
    text("title", { required: true }),
    text("subtitle", { required: true }),
    object("cta", {
      fields: [text("label", { required: true }), link("link")],
    }),
  ],
});

type Hero = z.infer<typeof hero.schema>;
// type Hero = {
//   title: string;
//   subtitle: string;
//   cta?: Record<string, ...> | null;  // ← label is gone, cta is wrong shape
// } | null
        

Fix it by capturing the literal type:


          const link = <TName extends string>(name: TName) =>
  union(name, { of: [external, internal] });
        

The built-in field constructors (text, number, relation, etc.) all follow this pattern — copy the same shape when writing your own.

Typing resolved relations

By default, relation fields are typed as { id, _entity } — a reference, not the full entity. When you resolve relations via the API, the response contains the full entity object. To type this, use the Resolve utility type.

Resolve<T, Map>

Resolve is a recursive type that finds every { id, _entity } relation reference in T and replaces it with the corresponding entity shape from Map:


          import type { Resolve } from "@cms/types";
import type { z } from "zod";
import { posts, authors, tags } from "./cms.config";

type Post = z.infer<typeof posts.schema>;
type Author = z.infer<typeof authors.schema>;
type Tag = z.infer<typeof tags.schema>;

type PostResolved = Resolve<
  Post,
  {
    authors: Author;
    tags: Tag;
  }
>;
// PostResolved["author"] → { id: string; _entity: "authors" } & Author
// PostResolved["tags"]   → ({ id: string; _entity: "tags" } & Tag)[]
        

The map keys match the _entity literal — the target entity name, not the field name. Resolve walks through arrays, nested objects, union variants, and optional/nullable wrappers automatically, so deeply nested relations inside blocks, objects, or rich text are all resolved in one pass.

Polymorphic resolution

For polymorphic relations, each branch resolves independently:


          type ArticleResolved = Resolve<
  Article,
  {
    staff: Staff;
    guest: Guest;
  }
>;
// ArticleResolved["contributor"] →
//   | ({ id: string; _entity: "staff" } & Staff)
//   | ({ id: string; _entity: "guest" } & Guest)
        

Unmapped targets fall back to { id: string; _entity: "<name>" } — the unresolved shape.

resolveSchema — runtime validation

Resolve is type-level only. If you also want runtime validation of resolved API responses, use resolveSchema from @cms/client:


          import { resolveSchema } from "@cms/client";

const resolvedPostSchema = resolveSchema(posts.schema, {
  authors: authors.schema,
  tags: tags.schema,
});

const { data } = await cms.find("posts", {
  resolve: { author: "*", tags: "*" },
  schema: resolvedPostSchema,
});
// data is runtime-validated and fully typed with resolved relations
        

resolveSchema walks the Zod schema tree and replaces { id, _entity } schemas with the resolved entity schemas. It handles ZodObject, ZodArray, ZodOptional, ZodNullable, ZodDiscriminatedUnion, and ZodDefault wrappers.

For polymorphic relations, it reconstructs a z.discriminatedUnion("_entity", [...]) from the matching entries in the map.

Manual approach (Omit + extend)

For simpler cases, you can still use Omit and intersection types:


          type PostWithAuthor = Omit<Post, "author"> & { author: Author };
        

Or transform the Zod schema directly:


          const postWithAuthorSchema = posts.schema
  .omit({ author: true })
  .extend({ author: authors.schema });
        

This works but doesn't scale to deeply nested relations. Prefer Resolve and resolveSchema for anything beyond flat top-level fields.

Partial resolution

When resolving only specific fields (resolve[author]=name,bio), narrow the type to match:


          type PostWithPartialAuthor = Resolve<
  Post,
  {
    authors: Pick<Author, "id" | "name" | "bio">;
  }
>;
        

Or with Zod:


          const partialAuthorSchema = authors.schema.pick({
  id: true,
  name: true,
  bio: true,
});

const resolvedSchema = resolveSchema(posts.schema, {
  authors: partialAuthorSchema,
});
        

Full example

Putting it all together — entity definitions, type inference, and typed API calls:


          // cms.config.ts
import { entity, text, relation, select } from "@cms/config";

export const authors = entity("authors", {
  fields: [text("name", { required: true }), text("bio")],
});

export const posts = entity("posts", {
  fields: [
    text("title", { required: true }),
    select("status", { options: ["draft", "published"], required: true }),
    relation("author", { to: authors }),
  ],
});
        

          // lib/schemas.ts
import type { Resolve } from "@cms/types";
import type { z } from "zod";
import { resolveSchema } from "@cms/client";
import { posts, authors } from "./cms.config";

export type Post = z.infer<typeof posts.schema>;
export type Author = z.infer<typeof authors.schema>;

// Type-level resolution
export type PostResolved = Resolve<Post, { authors: Author }>;

// Runtime schema for validated API calls
export const postResolvedSchema = resolveSchema(posts.schema, {
  authors: authors.schema,
});
        

          // lib/api.ts
import { createClient } from "@cms/client";
import { postResolvedSchema } from "./schemas";

const cms = createClient({ url: "/api" });

// With schema — runtime validated, type inferred
const { data: post } = await cms.find("posts", {
  filter: { status: "published" },
  resolve: { author: "*" },
  schema: postResolvedSchema,
});

// With generic — cast only, no runtime validation
const { data: posts } = await cms.list<Post>("posts", {
  filter: { status: "published" },
  sort: "-createdAt",
});
        

Previous

Relations

Next

Querying