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, pluslocaleandtranslationGroupfor translatable entities) - Select fields produce literal unions (
"draft" | "published") - Object fields produce nested object types
- Union fields produce discriminated unions with a
_typediscriminator - Relation fields produce
{ id: string; _entity: "<target>" }— where_entityis 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