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 "/config";
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 a relation field is typed as { id, _entity } — a reference, not the full entity. When you resolve a relation with the client’s inline resolve tree, the response type is computed from the selection: the bare reference is replaced in place by the resolved object carrying exactly the fields you projected. There’s no resolution type to write by hand and no runtime schema to keep in sync.


          const { data } = await cms.find("posts", {
  resolve: { author: { name: true, bio: true } },
});
// data.author → { id: string; _entity: "authors"; name: string; bio: string | null }
        

The same inference covers polymorphic targets (a discriminated union on _entity, with a field absent on a branch omitted), relations nested in blocks (keyed by variant _type), and chained relations up to the 2-hop cap. See the client reference for the full grammar.

Reusing the resolved type

Need the resolved shape as a named type elsewhere? Derive it from the call rather than reconstructing it by hand:


          const result = await cms.find("posts", {
  resolve: { author: { name: true } },
});

type ResolvedPost = NonNullable<typeof result["data"]>;
        

Full example

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


          // 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 }),
  ],
});

export default config({ entities: [authors, posts] /* … */ });
        

          // lib/api.ts — config is a type-only generic; the runtime never imports it
import { createClient } from "@cms/client";
import type cmsConfig from "../cms.config";

const cms = createClient<typeof cmsConfig>({ url: "/api" });

// Entity name is a checked union; the result type follows the selection.
const { data: post } = await cms.find("posts", {
  filter: { status: "published" },
  fields: { title: true, status: true },
  resolve: { author: { name: true, bio: true } },
});
// post?.author → { id: string; _entity: "authors"; name: string; bio: string | null }
        

Previous

Relations

Next

Querying