Skip to content

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
<typeof
Recipe
>

The set of keys is closed and follows the JSON Schema annotation vocabulary, plus guidelines:

KeyWhat it says
titleA short name for the value
descriptionWhat the value is
guidelinesHow to write a good one, for whoever authors it
examplesValid values
deprecatedThe reason, and what to use instead
readOnlyThe 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
<typeof
Venue
>

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 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'})})