--- url: /typed-schema/guide/why.md --- # Why typed schemas ## Constraints checked at compile time Sanity accepts almost any shape of content, but it does have rules. A classic Sanity schema reports violations of those rules at runtime, usually when the Studio reloads during development. With typed schemas the same violations are type errors in your editor and in `tsc`: 1. Arrays of arrays. 2. Arrays that mix primitive values and objects. 3. Unions of untyped objects. Object unions must consist of objects with a `_type` literal, because a partially edited object (an empty one, say) is otherwise impossible to match to a schema type during real-time collaboration. 4. Field names that start with an underscore. These are reserved for system fields. 5. Field names that don't start with a letter A-Z, or that contain anything other than letters A-Z, digits and underscores (a hyphen, for example). 6. Field values that are not schemas, such as `title: 'Title'` instead of `title: s.string()`. 7. Documents without a `_type` literal, and document `_id` fields that are not strings. ```ts import * as s from '@sanity-labs/typed-schema' // arrays of arrays are not allowed, such as rows of seats const Seating = s.array(s.array(s.string())) // arrays can't mix primitives and objects const Mixed = s.array( s.union([s.string(), s.object({_type: s.literal('link'), href: s.string()})]), ) // object union members need a _type literal const LinkOrNote = s.union([ s.object({url: s.string()}), s.object({text: s.string()}), ]) const Person = s.object({ // fields starting with _ are reserved for system fields _nickname: s.string(), // field names can't contain spaces or other special characters 'first name': s.string(), }) ``` Each error names a `SchemaError` that says what to fix. For the `'first name'` field: ``` Type 'StringSchema' is not assignable to type 'StringSchema & SchemaError<"Field name 'first name' can only contain letters A-Z, digits and underscores">'. ``` The [playground](/playground) shows these errors as you type. ## Sharing a content model outside the Studio A classic Sanity schema mixes the content model with presentation: input components, previews, field groups and titles. That makes the schema hard to use outside the Studio and hard to reuse across teams. A typed schema is plain data describing content only. It can be: * published to npm and versioned with semver, so a breaking change to a shared type fails the downstream build instead of failing in production, * used in any runtime (Node, edge, browser) without pulling in React, * used to validate content coming from another system before it is written to Sanity. Several variants of one type can live side by side: an `EventV2` that renames `organizer` to `host`, or a type with one field removed for a specific context. ## Sanity conventions at the type level Sanity attaches data to your content that you did not declare. Typed schemas model it: 1. Objects inside arrays get a `_key: string`. The inferred array element type includes it, so there is no `item as T & {_key: string}`. 2. Documents get `_id`, `_rev`, `_createdAt` and `_updatedAt`. 3. Whether a document is complete and whether it has been committed to the Content Lake are two separate questions, and each has its own transform. A document fetched from the API always has `_id`, `_rev`, `_createdAt` and `_updatedAt`; one you construct locally does not yet. A document read from the `drafts` perspective, or one being edited, may be missing fields. See [Document lifecycles](./lifecycles). ## Migrating content in steps When a content model changes, the old and the new version can live side by side, with the new one derived from the old (`extend`, `omit`, `pick`, `rename`). A migration is then a function from one inferred type to the other, and the compiler checks every field of the result: ```ts import * as s from '@sanity-labs/typed-schema' const EventV1 = s.document({ _type: s.literal('event'), title: s.string(), organizer: s.string(), }) // version 2 renames organizer to host const EventV2 = s.extend(s.omit(EventV1, ['organizer']), { host: s.string(), }) type EventV1 = s.Infer type EventV2 = s.Infer function adapt({organizer, ...event}: EventV1): EventV2 { return {...event, host: organizer} } ``` The same function can adapt documents as they are read, so a dataset can be migrated gradually. Try the new version first, and convert from the old one only if that fails: ```ts // EventV1, EventV2 and adapt() are from the example above function parseEvent(input: unknown): s.ParseResult { const v2 = s.parse(EventV2, input) if (v2.ok) return v2 const v1 = s.parse(EventV1, input) // if the input is neither version, report how it fails to match V2 return v1.ok ? {ok: true, value: adapt(v1.value)} : v2 } // {ok: true, value: {_type: 'event', title: 'Book club', host: 'Ada'}} parseEvent({_type: 'event', title: 'Book club', organizer: 'Ada'}) ``` This lowers the risk of a content migration. Frontends that read documents through `parseEvent` work with the V2 shape before any content has changed, so you can ship them first and then migrate the dataset incrementally, without a moment where code and content disagree. [`examples/content-migration`](https://github.com/sanity-labs/typed-schema/tree/main/examples/content-migration) shows a complete migration. ## Why not Zod? [Zod](https://zod.dev/) is the inspiration for this library, and nothing here replaces it for general validation. Zod accepts values a Sanity document cannot hold (`Set`, `Map`, functions, promises) and has no notion of `_key`, `_type`, references, drafts, or the field-name rules above. This library covers the subset Sanity can store and builds those conventions in. --- --- url: /typed-schema/guide/getting-started.md --- # Getting started Requires TypeScript 6.0 or later with `strict: true` in `tsconfig.json`. ::: code-group ```sh [npm] npm install @sanity-labs/typed-schema ``` ```sh [pnpm] pnpm add @sanity-labs/typed-schema ``` ```sh [yarn] yarn add @sanity-labs/typed-schema ``` ```sh [bun] bun add @sanity-labs/typed-schema ``` ::: The package is published as ES modules only. ## A first schema The examples on this site import the package as a namespace, `import * as s from '@sanity-labs/typed-schema'`, so creators read as `s.document()`, `s.string()` and so on. Named imports work just as well. Every schema is a value built from creator functions. `Infer` turns a schema into its TypeScript type, and `parse` checks a value against the schema at runtime: ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({ _type: s.literal('recipe'), title: s.string(), servings: s.optional(s.number()), }) type Recipe = s.Infer const response = await fetch('https://example.com/recipes/pancakes.json') const result = s.parse(Recipe, await response.json()) if (result.ok) { const recipe = result.value } ``` Hover any name in the examples on this site to see its type. To try your own schemas, open the [playground](/playground). ## Next steps * [Declaring a document](./declaring-a-document) covers the creators you will use most. * [Parsing and errors](./parsing) explains runtime validation. * [Why typed schemas](./why) describes what the library checks that a classic schema does not. --- --- url: /typed-schema/guide/declaring-a-document.md --- # Declaring a document `document()` declares a document type. Its fields are schemas made with the other creators, and `Infer` turns the result into a TypeScript type. ```ts import * as s from '@sanity-labs/typed-schema' export const User = s.document({ _type: s.literal('user'), name: s.string(), idOrHandle: s.union([s.string(), s.number()]), isAdmin: s.optional(s.boolean()), address: s.object({ city: s.optional(s.string()), }), }) export type User = s.Infer const result = s.parse(User, { _type: 'user', name: 'Grace Hopper', idOrHandle: 'grace', isAdmin: true, address: {city: 'Arlington'}, }) if (result.ok) { const user = result.value user.address // a typo in a field name is a type error user.adress } ``` Schema constants are PascalCase (`User`, `Address`), the same as the type inferred from them. TypeScript lets a `const` and a `type` share a name, so `User` is the schema in a value position and the parsed document type in a type position, and one import gives you both. Creator and transform functions (`document()`, `reference()`, `extend()`) stay camelCase, and `_type` literals keep Sanity's camelCase type names (`'user'`). A document schema needs a `_type` literal, and gets the system fields `_id`, `_rev`, `_createdAt` and `_updatedAt` (see [`document()`](/api/schema-types#document)). Use `object()` for nested objects; give it a `_type` literal when the object is a member of a union. See the [schema types reference](/api/schema-types) for every creator. --- --- url: /typed-schema/guide/parsing.md --- # Parsing and errors `parse` checks a value against a schema and returns a typed copy of it. Properties the schema does not declare are stripped rather than rejected. ## `parse` `parse(schema, input)` never throws for invalid input. It returns `{ok: true, value}`, or `{ok: false, error}` where `error` is a `ParseError` that lists every problem found, not only the first: ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({ _type: s.literal('recipe'), title: s.string(), servings: s.number(), }) type Recipe = s.Infer const response = await fetch('https://example.com/recipes/pancakes.json') // here the response is {"_type": "recipe", "title": 42} const input = await response.json() const result = s.parse(Recipe, input) if (result.ok) { result.value } else { // [ // {path: ['title'], code: 'INVALID_TYPE', message: 'Expected a string'}, // {path: ['servings'], code: 'INVALID_TYPE', message: 'Expected a finite number'}, // ] console.log(result.error.issues) } ``` The value is `Parsed`: `Infer` plus a `$refSchema` key on reference values. The key is non-enumerable, so `JSON.stringify` skips it; see [Loading documents and references](./loaders). ## `parseOrThrow` `parseOrThrow(schema, input)` returns the value directly, and throws the same `ParseError` that a failed `parse` result holds: ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({ _type: s.literal('recipe'), title: s.string(), servings: s.number(), }) type Recipe = s.Infer const response = await fetch('https://example.com/recipes/pancakes.json') // here the response is {"_type": "recipe", "title": 42} const input = await response.json() try { const recipe = s.parseOrThrow(Recipe, input) } catch (err) { // only handle parse errors; let anything else propagate if (!(err instanceof s.ParseError)) throw err // Invalid input (2 issues): // title: Expected a string // servings: Expected a finite number console.error(err.message) } ``` ## Error details Each item in `error.issues` is a `ParseIssue` with a `path`, a `code` and a `message`. A path holds field names, array indexes and, for items in arrays of objects, `{_key, index}` segments with the item's key and its position in the input. The codes are listed in the [parsing API reference](/api/parsing). The error's `message` is a report of every issue, one per line. `formatIssues(issues)` writes the same report, and `formatPath(path)` writes a single path the way Sanity does, such as `body[_key=="abc"].children[0].text`. The empty path, for an issue with the value as a whole, is written ``. Both also accept issues and paths from any [Standard Schema](./standard-schema) library. Issues leave out the rejected value by default, so error messages and logged issues do not leak untrusted or sensitive input. Pass `{reportInput: true}` as the third argument to `parse` or `parseOrThrow` to include it: each issue then has an `input`, and the report shows it (`title: Expected a string, got 42`). --- --- url: /typed-schema/guide/standard-schema.md --- # Standard Schema Every schema implements [Standard Schema](https://standardschema.dev), so libraries that support it accept typed schemas directly, with no adapter. The Standard Schema site has the spec and a list of the libraries that support it. This [Hono](https://hono.dev) route receives a Sanity webhook and validates its body with [`@hono/standard-validator`](https://github.com/honojs/middleware/tree/main/packages/standard-validator): ```ts import {sValidator} from '@hono/standard-validator' import * as s from '@sanity-labs/typed-schema' import {Hono} from 'hono' const Recipe = s.document({ _type: s.literal('recipe'), title: s.string(), servings: s.optional(s.number()), }) const app = new Hono() app.post( '/webhooks/recipe-published', sValidator('json', Recipe, (result, c) => { if (!result.success) { // for a body with title: 42: // Invalid input (1 issue): // title: Expected a string return c.text(s.formatIssues(result.error), 400) } }), c => { const recipe = c.req.valid('json') return c.text(`Received ${recipe.title}`) }, ) ``` ## How typed schemas implement it * `validate` runs [`parse`](./parsing) and returns synchronously. In code that only uses typed schemas, call `parse` or `parseOrThrow` instead: they return the same value, and their issues also have a `code`. * The input type is the value as stored (`Infer`). The output type is what `parse` returns (`Parsed`), which adds a non-enumerable `$refSchema` key to reference values; see [Loading documents and references](./loaders). * In issue paths, an item in an array of objects appears as `{key: 0, _key: 'a1'}`. A library that follows the spec finds the item by its position in `key`; code that knows about Sanity can use the `_key`. * `formatIssues` and `formatPath` accept Standard Schema issues and paths from any library, and write them the way `ParseError.message` does. * The `~standard` property is non-enumerable, like `output`, so `JSON.stringify` and `Object.keys` skip it and schemas stay plain data. * The package exports the `StandardSchemaV1` type, so code that accepts a schema from any library does not need `@standard-schema/spec`. --- --- url: /typed-schema/guide/arrays.md --- # Arrays and `_key` An array holds either primitive values or object-like values. Sanity requires every object in an array to have a `_key`, so `array()` adds `_key: string` to the type of object elements. ## Arrays of primitives Arrays of strings, numbers, booleans, dates and literals hold plain values, without keys: ```ts import * as s from '@sanity-labs/typed-schema' const Tags = s.array(s.string()) type Tags = s.Infer ``` ## Arrays of objects Declare the object without a `_key`, and `array()` adds one to the element type. This applies to every object-like element: objects, references, images, files, blocks and unions of those. ```ts import * as s from '@sanity-labs/typed-schema' const Address = s.object({city: s.string()}) type Address = s.Infer const Addresses = s.array(Address) type Addresses = s.Infer ``` The key belongs to the array, not to the object schema, and it is not generated. Parsing an array requires each element to have one: ```ts import * as s from '@sanity-labs/typed-schema' const Address = s.object({city: s.string()}) const Addresses = s.array(Address) // on its own, an address needs no key: // {ok: true, value: {city: 'Oslo'}} s.parse(Address, {city: 'Oslo'}) // in an array, every element needs one. The result is {ok: false, error}, // and error.issues is // [{path: [0], code: 'ARRAY_ELEMENT_NOT_KEYED_OBJECT', ...}] s.parse(Addresses, [{city: 'Oslo'}]) ``` ## Element schemas An array schema's `element` is the element schema with `_key` added, and `element.unkeyed` is the schema given to `array()`. Use them to parse or type one item, or to get the item type of an array declared inline. See [`array()`](/api/schema-types#array) for examples. ## Nesting arrays Sanity does not allow arrays of arrays, so `array(array(Dish))` is a type error. The [`array()` reference](/api/schema-types#array) lists the other elements an array can't have. To nest arrays, wrap each inner array in an object. A menu becomes a list of sections, each section an object holding its dishes: ```ts import * as s from '@sanity-labs/typed-schema' const Dish = s.object({ _type: s.literal('dish'), name: s.string(), vegetarian: s.boolean(), }) const MenuSection = s.object({ _type: s.literal('menuSection'), title: s.string(), dishes: s.array(Dish), }) const Menu = s.array(MenuSection) type Menu = s.Infer ``` --- --- url: /typed-schema/guide/portable-text.md --- # Portable Text A Portable Text field is an array of blocks. `block()` describes one block, and `portableText()` builds the array, with the other items allowed between the blocks. With nothing between the blocks, use `array(block)`. ## A minimal block With no options, a block accepts any style, any list type and spans with any marks, and no inline objects or annotations: ```ts import * as s from '@sanity-labs/typed-schema' const Note = s.document({ _type: s.literal('note'), body: s.array(s.block({})), }) type Note = s.Infer type NoteBlock = Note['body'][number] ``` The inferred type matches what Sanity stores: `style`, `listItem`, `level` and `markDefs` are optional, and `children` holds spans. `marks` is a `string[]`, since it holds both decorator names and the `_key`s of entries in `markDefs`. ## Configuring a block Each option narrows one part of the block. `style` and `list` set the allowed block styles and list types, `decorator` names the span decorators, `inline` sets the objects allowed among the spans, and `annotation` sets the objects allowed in `markDefs`, such as links. The [`block()` reference](/api/schema-types#block) lists what each option takes. ```ts import * as s from '@sanity-labs/typed-schema' const Person = s.document({_type: s.literal('person'), name: s.string()}) type Person = s.Infer const ArticleBlock = s.block({ style: s.union([s.literal('normal'), s.literal('h2'), s.literal('h3')]), list: s.union([s.literal('bullet'), s.literal('number')]), decorator: s.union([s.literal('strong'), s.literal('em')]), inline: s.object({_type: s.literal('mention'), person: s.reference(Person)}), annotation: s.object({_type: s.literal('link'), href: s.string()}), }) type ArticleBlock = s.Infer function headings(blocks: ArticleBlock[]) { return blocks.filter(block => block.style === 'h2' || block.style === 'h3') } function mentions(block: ArticleBlock) { return block.children.flatMap(child => { if (child._type !== 'mention') return [] return [child.person._ref] }) } ``` A style outside the union is a type error in a block you write yourself, and `parse` rejects it in data that comes from elsewhere. ## Objects between blocks `portableText({block, element})` is the array of blocks plus the block-level objects allowed between them, such as images or code samples. `element` takes a typed object, a reference, an image, a file or a union of these, or `never()` for none: ```ts import * as s from '@sanity-labs/typed-schema' const Body = s.portableText({ block: s.block({ style: s.union([s.literal('normal'), s.literal('h2')]), annotation: s.object({_type: s.literal('link'), href: s.string()}), }), element: s.union([ s.image({alt: s.string()}), s.object({ _type: s.literal('codeBlock'), language: s.string(), code: s.string(), }), ]), }) type Body = s.Infer type CodeBlock = Extract function describe(body: Body) { return body.map(item => { switch (item._type) { case 'block': return item.style ?? 'normal' case 'image': return `image: ${item.alt}` case 'codeBlock': return `${item.language} code` } }) } ``` Every item has a `_key`, since it is an object in an array. Narrowing on `_type` gives each branch its own fields. ## Naming a block type `_type` names the block type, `block` by default. Give a block a name of its own when one schema uses two differently configured blocks, for example a rich article body and a caption that allows only emphasis. Unique names are also what [GraphQL deployment](./graphql) requires: ```ts import * as s from '@sanity-labs/typed-schema' const Figure = s.object({ _type: s.literal('figure'), image: s.image(), caption: s.array( s.block({ _type: s.literal('captionBlock'), style: s.literal('normal'), decorator: s.literal('em'), }), ), }) type Figure = s.Infer type CaptionBlock = Figure['caption'][number]['_type'] ``` ## Parsing Portable Text `parse` checks every item: blocks against the block schema, other items against the member with the same `_type`. An item with an unknown `_type`, or one without a `_key`, is an issue with its path: ```ts import * as s from '@sanity-labs/typed-schema' const Body = s.portableText({ block: s.block({style: s.union([s.literal('normal'), s.literal('h2')])}), element: s.object({_type: s.literal('codeBlock'), code: s.string()}), }) type Body = s.Infer const result = s.parse(Body, [ { _type: 'block', _key: 'a1', children: [{_type: 'span', _key: 's1', text: 'Hello', marks: []}], }, {_type: 'video', _key: 'b2'}, {_type: 'codeBlock', code: 'npm install'}, ]) if (!result.ok) { // Invalid input (2 issues): // [_key=="b2"]: Type "video" not found among valid union types // [2]: Expected an object with a "_key" property console.error(result.error.message) } ``` See [`block()`](/api/schema-types#block) and [`portableText()`](/api/schema-types#portabletext) in the reference for every option. --- --- url: /typed-schema/guide/composing.md --- # Composing schemas Shapes are plain objects, and schemas can be derived from other schemas. Each transform below returns a new schema, and the inferred types follow. ## Adding fields There are two ways to add fields to an existing schema: spread its shape into a new one, or use `extend`. Both give the same fields, and in both a field with the same name as an existing one replaces it. `extend` also keeps the schema's [annotations](./annotations) apart from `examples`, while a spread starts a new schema without any: ```ts import * as s from '@sanity-labs/typed-schema' const Person = s.object({ name: s.string(), email: s.string(), phone: s.optional(s.string()), }) const Volunteer = s.object({...Person.shape, role: s.string()}) type Volunteer = s.Infer const Speaker = s.extend(Person, {talk: s.string()}) type Speaker = s.Infer ``` Use `extend` when the new fields include a getter for a [recursive schema](./recursive-schemas): spreading a shape into the same object literal as a getter breaks type inference. `extend` also takes a `union` of objects and adds the fields to every member. Each member keeps its own `_type`, so the result is still a union of typed objects. This is how the section types of a page builder can share fields: ```ts import * as s from '@sanity-labs/typed-schema' const Hero = s.object({_type: s.literal('hero'), title: s.string()}) const Gallery = s.object({_type: s.literal('gallery'), columns: s.number()}) const Section = s.extend(s.union([Hero, Gallery]), { anchor: s.optional(s.string()), }) type Section = s.Infer ``` A union with a `reference`, `block`, `image` or `file` member cannot be extended: those types have fixed shapes. `_type` cannot be replaced on a union either, since the members are told apart by it. ## Removing fields with `omit` ```ts import * as s from '@sanity-labs/typed-schema' const Person = s.object({ name: s.string(), email: s.string(), phone: s.optional(s.string()), }) // leave out the phone number where people are listed publicly const PublicPerson = s.omit(Person, ['phone']) type PublicPerson = s.Infer ``` ## Keeping fields with `pick` ```ts import * as s from '@sanity-labs/typed-schema' const Person = s.object({ name: s.string(), email: s.string(), phone: s.optional(s.string()), }) const Contact = s.pick(Person, ['name', 'email']) type Contact = s.Infer ``` `omit` and `pick` on a document keep the system fields (`_id`, `_rev`, ...) even if you list them. ## Changing the type name with `rename` `rename` replaces a schema's `_type` literal: ```ts import * as s from '@sanity-labs/typed-schema' const Writer = s.object({_type: s.literal('writer'), name: s.string()}) type Writer = s.Infer const Author = s.rename(Writer, 'author') type Author = s.Infer ``` --- --- url: /typed-schema/guide/custom-creators.md --- # Custom creators The built-in creators (`object()`, `array()`, `block()`, ...) are ordinary functions that return schemas. When the same structure appears in several places with different contents, write a creator of your own the same way. ## Example: a table creator A table is a list of rows, each holding a list of cells. Since Sanity does not allow arrays of arrays, each row is an object. Like `block()`, `table()` takes an options object: an optional `_type` that defaults to `table`, and the schema for a cell, which can be any object or primitive schema, but not another array: ```ts import * as s from '@sanity-labs/typed-schema' function table< CellType extends | s.ObjectLikeSchema | s.ObjectUnionSchema | s.PrimitiveSchema | s.PrimitiveUnionSchema, TypeName extends s.LiteralSchema = s.LiteralSchema<'table'>, >(options: {_type?: TypeName; cell: CellType}) { return s.object({ _type: (options._type ?? s.literal('table')) as TypeName, rows: s.array(s.object({cells: s.array(options.cell)})), }) } const Session = s.object({ _type: s.literal('session'), title: s.string(), room: s.string(), }) // sessions by time slot and track const Timetable = table({_type: s.literal('timetable'), cell: Session}) type Timetable = s.Infer const NumberGrid = table({_type: s.literal('numberGrid'), cell: s.number()}) type NumberGrid = s.Infer ``` ## Build on other creators A creator can call another one to narrow what it accepts. `objectTable()` is a `table()` that only accepts object cells. `emailTable()` fixes both the cell and the type name, so callers pass nothing: ```ts // table() is the creator from the example above function objectTable< CellType extends s.TypedObjectSchema | s.ObjectUnionSchema, TypeName extends s.LiteralSchema = s.LiteralSchema<'table'>, >(options: {_type?: TypeName; cell: CellType}) { return table(options) } function emailTable() { return table({_type: s.literal('emailTable'), cell: s.string()}) } const Measurement = s.object({ _type: s.literal('measurement'), value: s.number(), unit: s.string(), }) const Note = s.object({_type: s.literal('note'), text: s.string()}) const MeasurementTable = objectTable({ _type: s.literal('measurementTable'), cell: Measurement, }) type MeasurementTable = s.Infer // measurements, with notes such as "not measured" in some cells const ResultsTable = objectTable({ _type: s.literal('resultsTable'), cell: s.union([Measurement, Note]), }) type ResultsTable = s.Infer const Contacts = emailTable() type Contacts = s.Infer ``` ## Constrain options with schema types Give each schema option a type parameter constrained by one of the library's schema types, such as `TypedObjectSchema` (an object with a `_type` literal), `ObjectUnionSchema` (a union of those) or `PrimitiveSchema` (a string, number, boolean, date, date-time or literal). The inferred type then follows whatever schema the caller passes, and passing the wrong kind of schema is a type error at the call site: ```ts // table() and objectTable() are the creators from the examples above const Tags = objectTable({cell: s.string()}) ``` ## Name each use Every call to a creator returns a new schema, so two tables with the default `_type` are two declarations of the type name `table`, which [`verifyUniqueTypeNames`](./graphql) rejects. Pass a `_type` to each table that holds different cells: ```ts // table() and objectTable() are the creators from the examples above const Measurement = s.object({ _type: s.literal('measurement'), value: s.number(), unit: s.string(), }) const Note = s.object({_type: s.literal('note'), text: s.string()}) const Experiment = s.verifyUniqueTypeNames( s.document({ _type: s.literal('experiment'), measurements: objectTable({ _type: s.literal('measurementTable'), cell: Measurement, }), results: objectTable({ _type: s.literal('resultsTable'), cell: s.union([Measurement, Note]), }), }), ) type Experiment = s.Infer ``` To use the same table in more than one place, call the creator once and reuse the schema it returns. --- --- url: /typed-schema/guide/partial-and-required.md --- # Partial and required variants A form lets users fill in fields gradually, so you want a type where everything is optional while they edit and a type where required fields are required when they save. Rather than making every field optional in the content model, derive the variants: | Function | Effect | | ------------------------- | ---------------------------------------------------------------- | | `shallowPartial(schema)` | Every top-level field becomes optional | | `shallowRequired(schema)` | Every top-level field becomes required | | `deepPartial(schema)` | Every field becomes optional, recursively through nested objects | | `deepRequired(schema)` | Every field becomes required, recursively through nested objects | | `required(schema)` | Removes `optional()` from a single schema | `_type` stays required in all of them. ```ts import * as s from '@sanity-labs/typed-schema' const ContactDetails = s.object({ name: s.string(), address: s.object({street: s.string(), city: s.string()}), email: s.string(), }) // Validate against this when the user saves type ContactDetails = s.Infer // Use this while the form is being filled in const PartialContactDetails = s.deepPartial(ContactDetails) type PartialContactDetails = s.Infer ``` --- --- url: /typed-schema/guide/lifecycles.md --- # Document lifecycles A document schema describes a complete document as you construct it locally. Drafts, documents fetched from the API and documents mid-edit are derived from it. `document()` makes the four system fields optional, so a new recipe needs its declared fields and nothing else: ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({_type: s.literal('recipe'), title: s.string()}) type Recipe = s.Infer const recipe: Recipe = {_type: 'recipe', title: 'Waffles'} ``` ::: tip Model the ideal document Write the schema for the document as it should be when it is complete and valid. Make a field optional only if a finished document can leave it out, not because it may be missing while someone edits it or in a draft. Derive those shapes with `partial()` and `committed()` instead of loosening the schema itself, so the schema stays the single description of what valid content looks like. ::: Two transforms move away from that shape, and they are independent of each other: * `partial(doc)` relaxes completeness: every field except `_type` becomes optional, recursively. This is what you hold while editing, and what the `drafts` or `raw` perspective may return. It is `deepPartial` restricted to documents. * `committed(doc)` adds persistence: `_id`, `_rev`, `_createdAt` and `_updatedAt` become required, because the Content Lake assigns them when it accepts a mutation. Every document the API returns is committed. Because the axes are independent, the four combinations are four schemas: | | Complete | May be incomplete | | ----------------- | ------------------- | ---------------------------- | | **Not committed** | `Recipe` | `partial(Recipe)` | | **Committed** | `committed(Recipe)` | `committed(partial(Recipe))` | Apply `committed` last: `partial` would make the system fields optional again. `DeepPartial` and `Committed` are the schema types these return, so `Infer>` is the type of a recipe as the API returns it. There is no separate value-level transform: derive the schema, then infer. ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({_type: s.literal('recipe'), title: s.string()}) type Recipe = s.Infer const committedRecipe = s.parse(s.committed(Recipe), { _type: 'recipe', _id: 'pancakes', _rev: 'abc', _createdAt: '2024-01-01T00:00:00Z', _updatedAt: '2024-01-01T00:00:00Z', title: 'Pancakes', }) if (committedRecipe.ok) { committedRecipe.value._rev } const PartialRecipe = s.partial(Recipe) type PartialRecipe = s.Infer const committedPartialRecipe = s.parse(s.committed(PartialRecipe), { _type: 'recipe', _id: 'drafts.pancakes', _rev: 'abc', _createdAt: '2024-01-01T00:00:00Z', _updatedAt: '2024-01-01T00:00:00Z', }) if (committedPartialRecipe.ok) { committedPartialRecipe.value.title } // A new recipe needs its declared fields but no system fields const newRecipe: Recipe = {_type: 'recipe', title: 'Waffles'} // A partial one needs neither const newPartialRecipe: PartialRecipe = {_type: 'recipe'} // Name the committed type without calling committed() at runtime type CommittedRecipe = s.Infer> ``` Published, draft and version are perspectives on committed documents rather than shapes of their own. The Content Lake tells them apart by `_id` prefix and by the `perspective` you query with, and under `perspective: 'drafts'` it returns the published `_id` and keeps the real one in `_originalId`. A type that encoded the prefix into `_id` would be wrong under some perspectives, so the library does not model the variant. In `@sanity/client` terms, `SanityDocumentStub` corresponds to the base schema and `SanityDocument` to `committed(doc)`. `IdentifiedSanityDocumentStub` (`_id` known, nothing else assigned) has no counterpart. The [playground](/playground?example=lifecycles) shows all four combinations side by side. --- --- url: /typed-schema/guide/recursive-schemas.md --- # Recursive schemas Declare a field that refers to its own schema, or to a schema defined further down, with a getter, the same way as in [Zod](https://zod.dev/api#recursive-objects). TypeScript infers the type without annotations: ```ts import * as s from '@sanity-labs/typed-schema' const Person = s.document({ _type: s.literal('person'), name: s.string(), get friends() { return s.array(s.reference(Person)) }, }) type Person = s.Infer ``` This also works for mutual recursion (a `User` with posts, each `Post` with an author), through `optional()`, `union()`, `portableText()` and `block()`, and in the fields passed to `extend()`. [Converting to a Studio schema](./studio) has an extra rule for recursion through images and files. The schema creators store the getter as a `lazy()` schema without calling it, so `Person.shape.friends` is a `lazy` schema at runtime even though its static type is the array schema. For the same reason, TypeScript does not check that a getter returns a schema. If it returns something else, parsing throws `Field "friends" is not a schema` the first time it reaches that field. ## When inference fails Spreading another shape into the same object literal as a getter makes TypeScript read the getter while it infers the schema, and the schema becomes `any` with error TS7022 ("implicitly has type 'any' because it does not have a type annotation"): ```ts import * as s from '@sanity-labs/typed-schema' const CategoryFields = s.object({title: s.string()}) const Category = s.object({ ...CategoryFields.shape, get subcategories() { return s.array(Category) }, }) ``` Add the getter with `extend` instead: ```ts import * as s from '@sanity-labs/typed-schema' const CategoryFields = s.object({title: s.string()}) const Category = s.extend(CategoryFields, { get subcategories() { return s.array(Category) }, }) type Category = s.Infer ``` --- --- url: /typed-schema/guide/annotations.md --- # Schema annotations A schema says what shape a value has. Annotations say what the value means and how to write a good one. Every creator except `optional()` and `never()` takes them as its last argument; `number()` takes them together with its constraints: ```ts import * as s from '@sanity-labs/typed-schema' export const Recipe = s.document( { _type: s.literal('recipe'), title: s.string({description: 'Shown as the heading on the recipe page'}), difficulty: s.enum(['easy', 'medium', 'hard'], { description: 'Used to filter the recipe list', }), servings: s.optional( s.number({ min: 1, step: 1, title: 'Servings', description: 'How many people the recipe feeds', examples: [4], }), ), tags: s.array(s.string(), { description: 'Lowercase keywords for search', guidelines: 'One word per tag, no duplicates', examples: [['vegetarian', 'soup']], }), subtitle: s.optional(s.string({deprecated: 'Put it in `title` instead'})), slug: s.string({readOnly: true, description: 'Derived from the title'}), }, { title: 'Recipe', description: 'A recipe published on the site', examples: [ { _type: 'recipe', title: 'Tomato soup', difficulty: 'easy', tags: ['soup'], slug: 'tomato-soup', }, ], }, ) export type Recipe = s.Infer ``` The set of keys is closed and follows the JSON Schema annotation vocabulary, plus `guidelines`: | Key | What it says | | ------------- | ----------------------------------------------------------------------------------------------------------- | | `title` | A short name for the value | | `description` | What the value is | | `guidelines` | How to write a good one, for whoever authors it | | `examples` | Valid values | | `deprecated` | The reason, and what to use instead | | `readOnly` | The system sets the value, such as a slug derived from the title, so editors and agents should not write it | Annotations describe the content model, not any one consumer of it. There are no keys for how a value is rendered, which input edits it or which tool reads it, so the same annotations serve a Studio form, an agent that writes content, generated documentation and a test suite. ## Checking examples `examples` are checked against the inferred type, so a wrong shape, a missing `_key` on an array item or a value outside an enum is a compile error: ```ts const Servings = s.number({examples: ['four']}) ``` The type system can't see constraints such as `min` on a number or the format of a `date`. `validateExamples(schema)` parses every example in the schema tree against its node and returns a result like `parse` does. Run it in a test: ```ts const Recipe = s.document({ _type: s.literal('recipe'), servings: s.optional(s.number({min: 1, step: 1, examples: [0, 4]})), published: s.date({examples: ['2026-13-01']}), }) const result = s.validateExamples(Recipe) if (!result.ok) { // Invalid schema examples: // servings examples[0]: Expected a number greater than, or equal to 1 // published examples[0]: Expected a date string on the format "YYYY-MM-DD" console.error(result.error.message) } ``` `validateExamplesOrThrow(schema)` returns the schema when every example parses, and throws the same `ExampleError` when one does not. The error's `failures` lists each failing example with its position in the schema and its parse issues. ## Annotations on shared schemas Annotations belong to the schema node they are given to. An `array()` and its element are two nodes, so each can have its own. A shared schema used as a field brings its annotations with it: ```ts const Address = s.object( {street: s.string(), city: s.string()}, {title: 'Address'}, ) const Venue = s.document({ _type: s.literal('venue'), // the field has Address's title address: Address, // a new node, with its own title postalAddress: s.object(Address.shape, {title: 'Postal address'}), tags: s.array(s.string({title: 'Tag'}), {description: 'Keywords'}), }) type Venue = s.Infer ``` To describe one use of a shared schema differently, build a new node from its shape, as `postalAddress` does. That replaces the annotations rather than merging them. Annotations survive `extend`, `pick`, `omit`, `rename` and the partial and required transforms. The exception is `examples`: `extend`, `rename`, `shallowRequired` and `deepRequired` drop them, since the source's examples may not be valid values of the result (it can have a new required field, a different `_type` or a field that is no longer optional). `pick`, `omit` and the partial transforms keep them, because every example still parses. ## Reading annotations `getAnnotations(schema)` returns a node's annotations, or `undefined` if it has none. It looks through `optional()` and `lazy()`, and annotations on the outer node win: ```ts s.getAnnotations(Recipe.shape.title)?.description // 'Shown as the heading on the recipe page' ``` [`toClassicSchema`](/guide/studio) maps `title`, `description`, `deprecated` and `readOnly` onto the Studio field definitions. ## Translations Any text can be given per locale. `getAnnotations(schema, {locale, fallback})` resolves it to a string, and so does `toClassicSchema(schema, {locale})`: ```ts const Title = s.string({ description: {en: 'The event title', nb: 'Tittelen på arrangementet'}, }) s.getAnnotations(Title, {locale: 'nb', fallback: 'en'})?.description // 'Tittelen på arrangementet' ``` The library doesn't know which locales a project uses, so a locale map accepts any keys. To require your own set, narrow `AnnotationText` where the text is written. A map with a missing or misspelled locale is then a compile error: ```ts type Text = s.AnnotationText<'en' | 'nb'> const t = (text: Text) => text s.string({description: t({en: 'The event title', no: 'Tittelen'})}) ``` --- --- url: /typed-schema/guide/loaders.md --- # Loading documents and references `createLoader(fetch)` wraps a function that fetches a document by id and returns typed `loadDocument` and `loadReference` helpers. The library does not talk to the Sanity API itself; you supply the fetch, for example with `@sanity/client`: ```ts import * as s from '@sanity-labs/typed-schema' import {createClient} from '@sanity/client' const client = createClient({projectId: 'abc123', dataset: 'production'}) const {loadDocument, loadReference} = s.createLoader((id, fields) => client.fetch(`*[_id == $id][0]{${fields.join(', ')}}`, {id}), ) const Author = s.document({_type: s.literal('author'), name: s.string()}) type Author = s.Infer const Post = s.document({ _type: s.literal('post'), title: s.string(), author: s.reference(Author), }) type Post = s.Infer const welcome = await loadDocument(Post, 'welcome') const writer = await loadReference(welcome.author) ``` `loadDocument` parses what the fetch returns against `committed(Post)`, so a document that does not match its schema, or that lacks `_id`, `_rev`, `_createdAt` or `_updatedAt`, rejects with a `ParseError`. `fields` is the list of top-level field names in the schema, including those four, so you can project only what you need. `loadReference` passes the fields of the schema the reference points to. `loadDocument` returns `Parsed>`, where each reference value includes the schema it points to under a non-enumerable `$refSchema` key, which `JSON.stringify` skips. That is how `loadReference(welcome.author)` knows to parse the result as an `Author`. A reference typed as `Infer['author']` has no `$refSchema`, for example one built by hand or read straight from the client. For those, `loadReference` fetches and parses only the document system fields, and the result is typed as a committed document with `_type`, `_id`, `_rev`, `_createdAt` and `_updatedAt` and nothing else. `createLoader` also returns `loadDocuments` and `loadReferences` for lists. Use `createDocumentLoader` or `createReferenceLoader` if you only need one of them. ## References to several document types A reference can point to documents of several types: pass `reference()` a `union()` of documents. The stored value doesn't say which type it points to, so `loadReference` fetches the fields of every document in the union and parses the loaded document against the schema whose `_type` matches. If the loaded document has any other `_type`, `loadReference` rejects with a `ParseError`. `reference()` throws if two documents in the union have the same `_type`; for `lazy()` documents, which resolve only at load time, `loadReference` rejects instead. ```ts const Author = s.document({_type: s.literal('author'), name: s.string()}) const Book = s.document({_type: s.literal('book'), title: s.string()}) const Review = s.document({ _type: s.literal('review'), subject: s.reference(s.union([Author, Book])), }) const review = await loadDocument(Review, 'cosmos-review') const subject = await loadReference(review.subject) if (subject._type === 'book') { console.log(subject.title) } ``` `loadReference(review.subject)` resolves to `Parsed> | Parsed>`, which you can narrow on `_type`. `union([reference(Author), reference(Book)])` is the same as `reference(union([Author, Book]))`. Every reference value has `_type: 'reference'`, so parsing can't tell several reference members apart, and `union()` combines them into one. It throws if one of those references, or a nested `union()` that contains one, has annotations. Put the annotations on a single `reference(union([...]))` instead. A document that references itself can be a member through a getter, and you can wrap it in `lazy()`: `reference(union([lazy(() => Person), Book]))`. A document typed by its value must be typed as `DocumentSchemaOf` to be a member, since a `Schema` could be an object at runtime. You can also use a `union()` of documents on its own to parse a value that can be one of several documents, such as a query result: `parse(union([Author, Book]), doc)`. It can't include other object types or be an `array()` element. `toClassicSchema` throws for a union of documents that isn't a reference target, since Studio can't store a document inside another document. --- --- url: /typed-schema/guide/graphql.md --- # GraphQL compatibility Sanity's GraphQL API generates one GraphQL type per `_type`, so every `_type` in a schema must map to exactly one shape. `verifyUniqueTypeNames(schema)` walks the schema and returns it unchanged, or throws a `DuplicateTypeNameError` if the same `_type` literal appears in more than one place. Wrapping a schema in it turns a duplicate into an error as soon as the module loads, in tests and in CI, instead of when you deploy the GraphQL API. ```ts import * as s from '@sanity-labs/typed-schema' const Post = s.verifyUniqueTypeNames( s.document({ _type: s.literal('post'), author: s.object({_type: s.literal('person'), name: s.string()}), }), ) type Post = s.Infer ``` A second object with `_type: 'person'` anywhere in the schema makes it throw a `DuplicateTypeNameError`: ```ts import * as s from '@sanity-labs/typed-schema' const Post = s.verifyUniqueTypeNames( s.document({ _type: s.literal('post'), author: s.object({_type: s.literal('person'), name: s.string()}), reviewer: s.object({_type: s.literal('person'), email: s.string()}), }), ) ``` The error message names the duplicated `_type` and the paths where it appears: ```txt Duplicate type names in schema: _type "person" at author, reviewer ``` To use a type in more than one place, declare it once and reuse the schema. Reusing the same schema counts as one type, while two separate declarations of `person` are rejected even if their fields match, so every type has one definition: ```ts import * as s from '@sanity-labs/typed-schema' const Person = s.object({_type: s.literal('person'), name: s.string()}) type Person = s.Infer const Post = s.verifyUniqueTypeNames( s.document({ _type: s.literal('post'), author: Person, reviewer: Person, }), ) type Post = s.Infer ``` --- --- url: /typed-schema/guide/studio.md --- # Using a typed schema in Sanity Studio `toClassicSchema(documentSchema, {locale?, fallback?})` converts a document schema to the array of schema type definitions a Studio config expects. It maps `title`, `description`, `deprecated` and `readOnly` from the [annotations](/guide/annotations) onto the classic definitions, resolving translated text to `locale`, or to `fallback` when `locale` has no entry. ::: warning Proof of concept `toClassicSchema` does not emit referenced document types, which fields are required, or Portable Text styles, decorators and annotations. Treat the result as a starting point. ::: Recursive object types become named top-level types, referred to by their `_type`. `toClassicSchema()` throws on a recursive object type without a `_type` literal. An image or file can't become a named type, since its `_type` is always `image` or `file`, so a cycle through one must also pass through an object with a `_type` literal or a `reference()`. ```ts import * as s from '@sanity-labs/typed-schema' import {defineConfig} from 'sanity' const Author = s.document({_type: s.literal('author'), name: s.string()}) type Author = s.Infer export default defineConfig({ projectId: 'abc123', dataset: 'production', schema: {types: s.toClassicSchema(Author)}, }) ``` [`examples/compat-studio`](https://github.com/sanity-labs/typed-schema/tree/main/examples/compat-studio) runs a Studio on it. --- --- url: /typed-schema/guide/limitations.md --- # Limitations Inference is only as precise as the schema literal TypeScript can see. A shape built dynamically infers as an index signature, with no named fields: ```ts import * as s from '@sanity-labs/typed-schema' const shape: Record> = {} for (const key of ['firstName', 'lastName']) { shape[key] = s.string() } const Person = s.object(shape) type Person = s.Infer const result = s.parse(Person, {firstName: 'Grace', lastName: 'Hopper'}) if (result.ok) { // typed as {[x: string]: string}, so a typo like person.fristName compiles const person = result.value } ``` Write shapes as literals (`object({firstName: string(), lastName: string()})`) to get narrow types. The [schema types reference](/api/schema-types) lists what each creator accepts and what is a type error. Two of the rules for `union()` are easy to miss: * Union members must all be primitives, all documents, or all typed objects, references, images, files and blocks. See [`union()`](/api/schema-types#union). * Pass `union()` members that include a `reference()` inline, or declare the variable with `as const`, so that `union()` can count the references. --- --- url: /typed-schema/api/schema-types.md --- # Schema types All creators are named exports of `@sanity-labs/typed-schema`. | Creator | Inferred type | | ----------------------------------------------- | --------------------------------------------------------------- | | [`string()`](#string) | `string` | | [`number()`](#number) and its aliases | `number` | | [`boolean()`](#boolean) | `boolean` | | [`literal()`](#literal) | the literal | | [`enum()`](#enum) | a union of the values | | [`date()`](#date) | `string` | | [`dateTime()`](#datetime) | `string` | | [`object()`](#object) | an object with the declared fields | | [`document()`](#document) | an object with the declared fields and the system fields | | [`array()`](#array) | an array, with `_key` on object elements | | [`union()`](#union) | a union of the members' types | | [`reference()`](#reference) | `{_type: 'reference'; _ref: string; _weak?: boolean}` | | [`image()`](#image) and [`file()`](#file) | `{_type: 'image' \| 'file'; asset: reference}` plus your fields | | [`imageAsset()`, `fileAsset()`](#asset-schemas) | the `sanity.imageAsset` and `sanity.fileAsset` documents | | [`geopoint()`](#geopoint) | `{_type: 'geopoint'; lat: number; lng: number; alt?: number}` | | [`block()`](#block) | one Portable Text block | | [`portableText()`](#portabletext) | an array of blocks and the objects between them | | [`optional()`](#optional) | the inner type, or `undefined` | | [`lazy()`](#lazy) | the type of the schema the getter returns | | [`never()`](#never) | `never` | ## Common to every schema {#common} A schema is a plain object with these properties: | Property | Notes | | ------------- | -------------------------------------------------------------------------------- | | `typeName` | The kind of schema: `'string'`, `'object'`, `'objectArray'`, `'union'`, ... | | `annotations` | The [annotations](/api/annotations) given as the creator's last argument, if any | | `~standard` | The [Standard Schema](/guide/standard-schema) properties. Not enumerable | Every creator except `optional()` and `never()` takes [annotations](/api/annotations) as its last argument. `number()` and its aliases take them in the same object as the constraints. `parse` leaves fields the schema does not declare out of the parsed value, and does not report them as issues. An `optional()` field set to `null` fails to parse, since `null` is not `undefined`. ## `string()` {#string} ```ts string(annotations?): StringSchema ``` * **Inferred type:** `string` * **Parsing:** Any string. Anything else is `INVALID_TYPE` ## `number()` {#number} ```ts number(options?: NumberConstraints & SchemaAnnotations): NumberSchema safeint(annotations?) int32(annotations?) uint32(annotations?) float32(annotations?) float64(annotations?) ``` * **Inferred type:** `number` * **Parsing:** A finite number within the constraints. `NaN`, `Infinity` and anything that is not a number are `INVALID_TYPE` * **Error codes:** `INVALID_TYPE`, `NUM_TOO_SMALL`, `NUM_TOO_BIG`, `NUM_NOT_MULTIPLE_OF` * **Properties:** `constraints`, which holds the options other than annotations, or is `undefined` if there are none | Option | Notes | | ------ | ---------------------------------------------------------------------- | | `min` | Smallest allowed value, inclusive | | `max` | Largest allowed value, inclusive | | `gt` | Smallest allowed value, exclusive | | `lt` | Largest allowed value, exclusive | | `step` | The value must be a multiple of `step`. `step: 1` allows whole numbers | Use `min` and `max`, or `gt` and `lt`. Mixing the two pairs is a type error. The aliases are `number()` with a preset range: | Alias | Range | Whole numbers | | ----------- | ----------------------------------------------- | ------------- | | `safeint()` | `Number.MIN_SAFE_INTEGER` to `MAX_SAFE_INTEGER` | yes | | `int32()` | -231 to 231-1 | yes | | `uint32()` | 0 to 232-1 | yes | | `float32()` | ±3.4028234663852886e38 | no | | `float64()` | ±`Number.MAX_VALUE` | no | ```ts import * as s from '@sanity-labs/typed-schema' const Rating = s.number({min: 1, max: 5, step: 1, title: 'Rating'}) // {ok: false, error}, with the issue code NUM_TOO_BIG s.parse(Rating, 6) ``` ## `boolean()` {#boolean} ```ts boolean(annotations?): BooleanSchema ``` * **Inferred type:** `boolean` * **Parsing:** `true` or `false`. Anything else is `INVALID_TYPE` ## `literal()` {#literal} ```ts literal(value: string | number | boolean, annotations?): LiteralSchema ``` * **Inferred type:** The literal type of `value`, such as `'post'` or `42` * **Parsing:** Only `value` itself (`===`). Anything else is `INVALID_TYPE` * **Properties:** `value` The `_type` field of a document or typed object is a `literal()`. A [union](#union) of literals is a closed set of values, like [`enum()`](#enum). ## `enum()` {#enum} ```ts enum(values: string[] | {[name: string]: string | number}, annotations?): EnumSchema ``` * **Inferred type:** A union of the values * **Parsing:** One of the values. Anything else is `INVALID_ENUM_VALUE` * **Properties:** `enum`, the values as an object. An array of values is stored as an object that maps each value to itself With an object, the values are the object's values, not its keys. An `enum()` can't be an array element or a union member; use a [union](#union) of literals there. `enum` is a reserved word in JavaScript, so a named import has to rename it: `import {enum as enumOf} from '@sanity-labs/typed-schema'`. With a namespace import, `s.enum()` works as is. `enums()` is a deprecated alias of `enum()`. ```ts import * as s from '@sanity-labs/typed-schema' const Status = s.enum(['draft', 'review', 'published']) type Status = s.Infer const Priority = s.enum({Low: 1, High: 2}) type Priority = s.Infer ``` ## `date()` {#date} ```ts date(annotations?): DateSchema ``` * **Inferred type:** `string` * **Parsing:** A calendar date written `YYYY-MM-DD`. `2024-02-30` and `2024-2-3` are `INVALID_TYPE` ## `dateTime()` {#datetime} ```ts dateTime(annotations?): DateTimeSchema ``` * **Inferred type:** `string` * **Parsing:** An ISO 8601 date and time with a time zone, `YYYY-MM-DDTHH:mm:ss[.sss](Z|±HH:mm)`. Without a time zone it is `INVALID_TYPE` ## `object()` {#object} ```ts object(shape: {[field: string]: Schema}, annotations?): ObjectSchema ``` * **Inferred type:** An object with a property per field. `optional()` fields are optional properties * **Parsing:** A plain object. `parse` checks each field with its schema, and adds the field name to the path of each issue it finds. Anything that is not an object is `INVALID_TYPE` * **Properties:** `shape`, the fields A field declared with a getter is stored as a [`lazy()`](#lazy) schema, so a field can refer to its own schema. See [Recursive schemas](/guide/recursive-schemas). An object with a `_type` field holding a string `literal()` is a *typed object*. An object must be typed to be a member of a [union](#union) or to be used in [Portable Text](#block). Type errors: * A field name that does not start with a letter A-Z, or that has characters other than letters A-Z, digits and `_`. The one exception is `_type`. * A field value that is not a schema. ```ts import * as s from '@sanity-labs/typed-schema' const Link = s.object({ _type: s.literal('link'), href: s.string(), label: s.optional(s.string()), }) type Link = s.Infer const Invalid = s.object({_internal: s.string()}) ``` ## `document()` {#document} ```ts document(shape: {_type: LiteralSchema; [field: string]: Schema}, annotations?): DocumentSchema ``` * **Inferred type:** The fields, plus the system fields `_id`, `_rev`, `_createdAt` and `_updatedAt`, all optional `string`s * **Parsing:** As for [`object()`](#object). `_type` must equal the literal * **Properties:** `shape`, the fields, including the system fields The system fields are optional because a document you create has none of them until the Content Lake commits it. [`committed()`](/guide/lifecycles) makes them required. To require `_id`, declare it in the shape with `string()` or a string `literal()`. Type errors, in addition to those of `object()`: * No `_type` field, or a `_type` that is not a string `literal()`. * An `_id` field that is not `string()` or a string `literal()`. ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({ _type: s.literal('recipe'), title: s.string(), }) type Recipe = s.Infer ``` See [Declaring a document](/guide/declaring-a-document). ## `array()` {#array} ```ts array(element: Schema, annotations?): ObjectArraySchema | PrimitiveArraySchema objectArray(element, annotations?): ObjectArraySchema primitiveArray(element, annotations?): PrimitiveArraySchema ``` * **Inferred type:** An array of the element type. Object-like elements get a required `_key: string` * **Parsing:** An array. `parse` checks each element with `element`. Anything that is not an array is `INVALID_TYPE` * **Error codes:** `INVALID_TYPE`, `ARRAY_ELEMENT_NOT_KEYED_OBJECT`, and the element's codes * **Properties:** `element`, the element schema, with `_key` added for object-like elements `array()` picks the kind of array from the element: * Objects, references, images, files, blocks and unions of these make an `objectArray`. Sanity requires a `_key` on every object in an array, so `array()` adds `_key` to the element schema. `parse` reports an element without a `_key` as `ARRAY_ELEMENT_NOT_KEYED_OBJECT`, with the element's index as the path. Issues inside an element have a `{_key, index}` path segment. * Strings, numbers, booleans, dates, literals and unions of these make a `primitiveArray`, without keys. Issues inside an element have the index as their path segment. `objectArray()` and `primitiveArray()` each accept only their own kind of element. Use them in [custom creators](/guide/custom-creators) that need one kind. On an object array, `element.unkeyed` is the schema given to `array()`, without `_key`. Each member of a union element also has an `unkeyed`. Use `element` and `element.unkeyed` to get the item types of an array declared inline, as a field of another schema: ```ts import * as s from '@sanity-labs/typed-schema' const Recipe = s.document({ _type: s.literal('recipe'), ingredients: s.array(s.object({name: s.string(), grams: s.number()})), }) type IngredientItem = s.Infer const Ingredient = Recipe.shape.ingredients.element.unkeyed type Ingredient = s.Infer ``` Type errors: * An array as the element, as in `array(array(...))`. Sanity does not allow arrays of arrays; wrap the inner array in an object. * A union that mixes primitives and objects. * An `optional()` element. * A `union()` of documents. For references to several document types, use `array(reference(union([...])))`. ```ts import * as s from '@sanity-labs/typed-schema' const Dish = s.object({_type: s.literal('dish'), name: s.string()}) const Mixed = s.array(s.union([s.string(), Dish])) const Menu = s.array(s.array(Dish)) ``` See [Arrays and `_key`](/guide/arrays). ## `union()` {#union} ```ts union(members: Schema[], annotations?): ObjectUnionSchema | DocumentUnionSchema | PrimitiveUnionSchema ``` * **Inferred type:** A union of the members' types. Nested unions are flattened, and `never()` members left out * **Properties:** `union`, the members, without `never()` All the members must be of one kind, which decides the kind of union: | Members | `typeName` | Parsing | | ---------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | Strings, numbers, booleans, dates, date-times and literals | `primitiveUnion` | The first member that accepts the value. If none does, `INVALID_PRIMITIVE_UNION` followed by each member's issue | | Typed objects, references, images, files and blocks | `union` | The member whose `_type` literal equals the value's `_type`. A value without a `_type`, or with one no member has, is `INVALID_OBJECT_UNION` | | Documents, and `lazy()` documents | `documentUnion` | As for typed objects | A union of documents is the target of a [reference](#reference) to several document types. It can also parse a value that can be one of several documents, such as a query result: `parse(union([Author, Book]), doc)`. It can't be an array element. Every reference value has `_type: 'reference'`, so a union cannot tell several references apart by `_type`. `union()` combines several `reference()` members into one reference to a union of their documents: `union([reference(Author), reference(Book), Link])` is `union([reference(union([Author, Book])), Link])`. It throws if one of those references, or a nested `union()` that contains one, has annotations; annotate a single `reference(union([...]))` instead. For the inferred type to match, `union()` needs to count the references. It can for members written inline, but not for a variable, which TypeScript types as an array of unknown length. Pass members that include a `reference()` inline, or declare the variable with `as const`: ```ts import * as s from '@sanity-labs/typed-schema' const Author = s.document({_type: s.literal('author'), name: s.string()}) const Link = s.object({_type: s.literal('link'), href: s.string()}) s.union([s.reference(Author), Link]) const members = [s.reference(Author), Link] s.union(members) const tuple = [s.reference(Author), Link] as const s.union(tuple) ``` Type errors: * Members of different kinds, such as a `string()` and an object, or a document and an object. * An object member without a `_type` literal. * A `lazy()` member that is not a document. * A member typed as `Schema`. Type it as `DocumentSchemaOf` or `ObjectSchemaOf`. * References in a variable that is not `as const`, as above. ```ts import * as s from '@sanity-labs/typed-schema' const Content = s.union([ s.object({_type: s.literal('quote'), text: s.string()}), s.object({_type: s.literal('link'), href: s.string()}), ]) type Content = s.Infer const Id = s.union([s.string(), s.number()]) type Id = s.Infer ``` ## `reference()` {#reference} ```ts reference(target: DocumentSchema | DocumentUnionSchema | LazySchema, annotations?): ReferenceSchema ``` * **Inferred type:** `{_type: 'reference'; _ref: string; _weak?: boolean}` * **Parsing:** As for [`object()`](#object), with the fields above. Other reference fields, such as `_strengthenOnPublish`, are left out * **Properties:** `referenceType`, the target, and `shape`, the reference fields `target` is a document, a `lazy()` document, or a `union()` of documents. `reference(union([Author, Book]))` can point to either type, and is the same as `union([reference(Author), reference(Book)])`. For documents that reference each other, declare the field with a getter; see [Recursive schemas](/guide/recursive-schemas). The value `parse` returns has the target under a non-enumerable `$refSchema` key, which [`loadReference`](/guide/loaders) uses to parse the referenced document. `Parsed<>` includes it and `Infer<>` leaves it out. Type errors: * A target that is not a document or a union of documents. `reference()` also throws when it is created, for: * An empty `union([])`. * A target typed as `Schema` that is an object at runtime. * A union with two documents of the same `_type`. ```ts import * as s from '@sanity-labs/typed-schema' const Author = s.document({_type: s.literal('author'), name: s.string()}) const Book = s.document({_type: s.literal('book'), title: s.string()}) const Review = s.document({ _type: s.literal('review'), author: s.reference(Author), subject: s.reference(s.union([Author, Book])), }) type Review = s.Infer ``` See [Loading documents and references](/guide/loaders). ## `image()` {#image} ```ts image(shape?, annotations?): ImageSchema ``` * **Inferred type:** `{_type: 'image'; asset: reference to imageAsset}` plus the fields in `shape` * **Parsing:** As for [`object()`](#object). `asset` is required. `hotspot` and `crop` are not declared yet, so they are left out of the parsed value * **Properties:** `shape`, with `_type`, `asset` and your fields `shape` takes extra fields, such as `alt`, with the same rules as `object()`. ```ts import * as s from '@sanity-labs/typed-schema' const Photo = s.image({alt: s.optional(s.string())}) type Photo = s.Infer ``` ## `file()` {#file} ```ts file(shape?, annotations?): FileSchema ``` * **Inferred type:** `{_type: 'file'; asset: reference to fileAsset}` plus the fields in `shape` * **Parsing:** As for [`object()`](#object). `asset` is required * **Properties:** `shape`, with `_type`, `asset` and your fields ## `imageAsset()` and `fileAsset()` {#asset-schemas} ```ts imageAsset(annotations?): DocumentSchema fileAsset(annotations?): DocumentSchema ``` The `sanity.imageAsset` and `sanity.fileAsset` documents, which the `asset` reference of an `image()` or `file()` points to. Both have `url`, `path`, `assetId`, `extension`, `mimeType`, `sha1hash` and `size`. Their optional fields are `originalFilename`, `uploadId`, `label`, `title`, `description`, `altText`, `creditLine` and `source`. An image asset also has `metadata`, with `dimensions`, `hasAlpha` and `isOpaque`. Its `location`, `palette`, `lqip` and `blurHash` are optional, since Sanity can't extract them from every image (an SVG has no palette, for example). Use them to parse asset documents, or as reference targets. ## `geopoint()` {#geopoint} ```ts geopoint(annotations?) ``` * **Inferred type:** `{_type: 'geopoint'; lat: number; lng: number; alt?: number}` * **Parsing:** As for [`object()`](#object) ## `block()` {#block} ```ts block(options: BlockOptions, annotations?): BlockSchema ``` * **Inferred type:** One Portable Text block: `{_type: 'block'; children; style?; listItem?; level?; markDefs?}` * **Parsing:** As for [`object()`](#object), with the fields above * **Properties:** `shape`, with `_type`, `style`, `listItem`, `level`, `children` and `markDefs` | Option | Takes | What it allows | | ------------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `style` | A string `literal()` or a union of them | Block styles, such as `normal` and `h2`. Any string if left out | | `list` | A string `literal()` or a union of them | List item types for `listItem`, such as `bullet` and `number`. Any string if left out | | `decorator` | A string `literal()` or a union of them | Decorators, such as `strong` and `em`. Spans don't check them: `marks` stays `string[]` | | `inline` | A typed object, reference, image, file or a union of these | Objects that can sit among the spans in `children`, such as mentions | | `annotation` | A typed object, reference, image, file or a union of these | Objects in `markDefs`, such as links, which spans refer to by `_key` in `marks` | | `_type` | A string `literal()` | The block's type name, `block` by default | `children` holds spans, `{_type: 'span'; _key: string; text: string; marks: string[]}`, and the `inline` objects. `marks` holds both decorator names and the `_key`s of entries in `markDefs`, so it is a `string[]`. Pass `{}` for a block with no options. Sanity Studio's own schema types allow only objects and references for `inline` and `annotation`. Type errors: * An option not in the table, or a value of the wrong kind for an option. ```ts import * as s from '@sanity-labs/typed-schema' const ArticleBlock = s.block({ style: s.union([s.literal('normal'), s.literal('h2')]), list: s.literal('bullet'), decorator: s.union([s.literal('strong'), s.literal('em')]), annotation: s.object({_type: s.literal('link'), href: s.string()}), }) type ArticleBlock = s.Infer ``` See [Portable Text](/guide/portable-text). ## `portableText()` {#portabletext} ```ts portableText({block, element}, annotations?): ObjectArraySchema ``` * **Inferred type:** An array of blocks and `element` objects, each with `_key` * **Parsing:** As for [`array()`](#array) of a [`union()`](#union) of typed objects * **Properties:** As for [`array()`](#array) | Option | Takes | | --------- | ------------------------------------------------------------------------------------------------------------------- | | `block` | A `block()` schema | | `element` | The objects allowed between blocks: a typed object, reference, image, file, a union of these, or `never()` for none | `portableText({block, element})` is `array(union([block, element]))`. With nothing between the blocks, `array(block)` is the same. ```ts import * as s from '@sanity-labs/typed-schema' const Body = s.portableText({ block: s.block({style: s.union([s.literal('normal'), s.literal('h2')])}), element: s.union([ s.image({alt: s.string()}), s.object({_type: s.literal('codeBlock'), code: s.string()}), ]), }) type Body = s.Infer ``` ## `optional()` {#optional} ```ts optional(schema: Schema): OptionalSchema ``` * **Inferred type:** The type of `schema`, or `undefined`. In an object, the field is an optional property * **Parsing:** `undefined` or a missing field, or a value `schema` accepts. `null` is not accepted * **Properties:** `type`, the inner schema `optional()` takes no annotations; annotate the inner schema. [`required()`](/api/transforms#modifiers) removes it. An array element can't be optional. ## `lazy()` {#lazy} ```ts lazy(getter: () => Schema, annotations?): LazySchema ``` * **Inferred type:** The type of the schema `getter` returns * **Parsing:** Calls `getter` the first time the schema is parsed, and parses with the schema it returns * **Properties:** `get`, the getter A field declared with a getter is stored as `lazy()`. A recursive schema written with `lazy()` needs a type annotation and a getter does not, so prefer a getter. See [Recursive schemas](/guide/recursive-schemas). ## `never()` {#never} ```ts never(): NeverSchema ``` * **Inferred type:** `never` * **Parsing:** Every value is `INVALID_TYPE` `union()` leaves `never()` members out, so it works as a placeholder member, as in `portableText({block, element: never()})`. It takes no annotations. --- --- url: /typed-schema/api/transforms.md --- # Modifiers and transforms ## Modifiers | Function | Notes | | -------------------- | -------------------------------------------------------------------------------------- | | `optional(schema)` | The field may be `undefined` or absent. See [`optional()`](/api/schema-types#optional) | | `required(schema)` | Removes `optional()`, however many times it was applied | | `lazy(() => schema)` | Defers a schema until it is parsed. See [`lazy()`](/api/schema-types#lazy) | ## Schema transforms Each returns a new schema. Types follow the runtime change. See [Composing schemas](/guide/composing) and [Partial and required variants](/guide/partial-and-required) for examples. | Function | Notes | | ------------------------- | -------------------------------------------------------------------------- | | `extend(schema, shape)` | Adds or replaces fields; on a `union()` of objects, every member gets them | | `omit(schema, keys)` | Removes fields. Document system fields are kept | | `pick(schema, keys)` | Keeps only the listed fields (plus system fields on documents) | | `rename(schema, newType)` | Replaces the `_type` literal | | `shallowPartial(schema)` | Top-level fields optional | | `shallowRequired(schema)` | Top-level fields required | | `deepPartial(schema)` | All fields optional, recursively | | `deepRequired(schema)` | All fields required, recursively | --- --- url: /typed-schema/api/parsing.md --- # Parsing | Export | Notes | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `parse(schema, input, options?)` | Returns `{ok: true, value}` or `{ok: false, error}`; never throws for invalid input | | `parseOrThrow(schema, input, options?)` | Returns `Parsed` or throws a `ParseError` | | `ParseOptions` | `{reportInput?: boolean}`. With `reportInput`, issues include the rejected value as `input` | | `ParseResult`, `ParseOk`, `ParseFail` | The types of a `parse` result | | `ParseError` | `message` is a report of every issue; `issues` holds them | | `ParseIssue` | `{path, code, message}`, plus `input` with `reportInput` | | `Path` | An array of field names, array indexes and `{_key, index}` segments for items in arrays of objects | | `formatPath(path)` | Writes a path the way Sanity does: `body[_key=="abc"].children[0].text`; `` for the empty path. Also takes Standard Schema paths | | `formatIssues(issues)` | Writes issues as a report, one per line; the same text as `ParseError.message`. Also takes the `issues` of any Standard Schema `validate` result | | `IssueLike` | What `formatIssues` accepts: `{message, path?, input?}`, which both `ParseIssue` and a Standard Schema issue satisfy | | `PathSegmentLike` | What `formatPath` accepts in a path: a field name, index, symbol, `{_key, index}` or Standard Schema `{key}` segment | | `schema['~standard']` | The [Standard Schema](/guide/standard-schema) properties; `validate` runs `parse` | | `ErrorCode` | One of the codes below | | Error code | Raised when | | -------------------------------- | ---------------------------------------------------- | | `INVALID_TYPE` | The value has the wrong type or format | | `INVALID_ENUM_VALUE` | The value is not one of the values given to `enum()` | | `INVALID_PRIMITIVE_UNION` | The value matches no member of a union of primitives | | `NUM_TOO_SMALL`, `NUM_TOO_BIG` | A number is outside the range given to `number()` | | `NUM_NOT_MULTIPLE_OF` | A number does not match the `step` constraint | | `INVALID_OBJECT_UNION` | No member of an object union matches the `_type` | | `ARRAY_ELEMENT_NOT_KEYED_OBJECT` | An object in an array has no `_key` | See [Parsing and errors](/guide/parsing) for examples. --- --- url: /typed-schema/api/annotations.md --- # Annotations Every creator except `optional()` and `never()` takes a `SchemaAnnotations` object as its last argument; `number()` and its aliases (`safeint()`, `int32()`, ...) take it together with the constraints. See [Schema annotations](/guide/annotations). | Export | Notes | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `SchemaAnnotations` | `{title?, description?, guidelines?, examples?, deprecated?, readOnly?}`. Text is a string or a locale map | | `AnnotationText` | `string \| {[locale]: string}`. Narrow `Locale` to require a project's locales | | `getAnnotations(schema, {locale?, fallback?})` | The annotations with text resolved to strings, or `undefined`. Looks through `optional()` and `lazy()`, outer wins | | `resolveAnnotationText(text, {locale?, fallback?})` | Resolves one `AnnotationText` value | | `validateExamples(schema)` | Parses every `examples` entry in the tree against its schema; returns `{ok: true, value}` or `{ok: false, error: ExampleError}` | | `validateExamplesOrThrow(schema)` | Same, returning the schema and throwing the `ExampleError` instead | | `ExampleError` | `failures`: each failing example with its `schemaPath`, `index`, `example` and parse `issues`. `message` lists them, one per line | | `toClassicSchema(schema, {locale?, fallback?})` | Maps `title`, `description`, `deprecated` and `readOnly` onto the classic definitions; see [Sanity Studio](/guide/studio) | | `ExampleFailure` | One entry in `ExampleError.failures` | | `ValidateExamplesResult` | What `validateExamples` returns | | `ResolvedSchemaAnnotations` | What `getAnnotations` returns: `SchemaAnnotations` with text resolved to strings | | `GetAnnotationsOptions` | `{locale?, fallback?}`, for `getAnnotations` and `resolveAnnotationText` | | `ToClassicSchemaOptions` | `{locale?, fallback?}`, for `toClassicSchema`; the same as `GetAnnotationsOptions` | --- --- url: /typed-schema/api/lifecycle.md --- # Lifecycle helpers | Export | Notes | | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `partial(doc)` | `deepPartial` for documents: every field but `_type` becomes optional, recursively. Returns `DeepPartial` | | `committed(doc)` | `_id`, `_rev`, `_createdAt`, `_updatedAt` become required | | `Committed` | The schema type `committed()` returns; `Infer>` is a document as the API returns it | | `COMMITTED_KEYS`, `CommittedKeys` | The fields `committed()` requires, as a constant and as a union of their names | | `verifyUniqueTypeNames(schema)`, `DuplicateTypeNameError` | See [GraphQL compatibility](/guide/graphql) | See [Document lifecycles](/guide/lifecycles) for examples. --- --- url: /typed-schema/api/loaders.md --- # Loaders | Export | Notes | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `createLoader(fetch)` | `fetch: (id, fieldNames) => Promise`. Returns `{loadDocument, loadDocuments, loadReference, loadReferences}` | | `createDocumentLoader(fetch)` | Only `loadDocument`. It resolves to `Parsed>` | | `createReferenceLoader(fetch)` | Only `loadReference`. It resolves to `Parsed>`, where `Target` is the referenced schema. For `reference(union([...]))`, it resolves to a union of that type, one per document | | `createLoadDocumentResult(fetch)` | Like `createDocumentLoader`, but the loader resolves to a `parse` result instead of rejecting with a `ParseError` | | `createLoadReferenceResult(fetch)` | Like `createReferenceLoader`, but the loader resolves to a `parse` result, and takes an optional list of fields to fetch in place of the target schema's | See [Loading documents and references](/guide/loaders) for an example with `@sanity/client`. --- --- url: /typed-schema/api/utility-types.md --- # Utility types | Type | Notes | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `Infer` | The value type of a schema as stored: plain data, without `$refSchema` on references | | `Parsed` | The `value` of a `parse` result, and what `parseOrThrow` and the loaders return: `Infer` plus `$refSchema` on every reference value | | `OutputOf` | The same as `Infer` | | `Schema` | Base type of every schema; `ObjectSchema`, `DocumentSchema`, `ArraySchema`, ... extend it | | `DocumentSchemaOf` | A document schema annotated by its value type | | `ObjectSchemaOf` | An object schema annotated by its value type | | `DocumentValue` | `{_type: string; _id?: string; _rev?: string; _createdAt?: string; _updatedAt?: string}` | | `ReferenceValue` | The value of a `reference()` to a schema of type `T` | | `ArrayValue` | `T[]`, with `_key` added to object elements | | `TypedObjectSchema` | Any object schema with a `_type` literal; use as a constraint in [custom creators](/guide/custom-creators) | | `ObjectUnionSchema` | Any `union()` of typed objects, references, images, files or blocks | | `PrimitiveSchema` | Any string, number, boolean, date, date-time or literal schema | | `PrimitiveUnionSchema` | Any `union()` of primitives | | `ObjectLikeSchema` | Any object, document, reference, block, image or file schema | | `ArraySchema` | The schema type `array()` returns for an element schema of type `T` | | `StandardSchemaV1` | The [Standard Schema](/guide/standard-schema) interface, the same as in `@standard-schema/spec` | The type guards (`isObjectSchema`, `isDocumentSchema`, `isOptionalSchema`, ...) are exported for code that walks schemas, along with `getInstanceName(schema)`, which returns the `_type` literal of an object or document schema, or `undefined` if it has none.