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, 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, 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