Skip to content

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
<typeof
Recipe
>
const
recipe
:
Recipe
= {
_type
: 'recipe',
title
: 'Waffles'}

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:

CompleteMay be incomplete
Not committedRecipepartial(Recipe)
Committedcommitted(Recipe)committed(partial(Recipe))

Apply committed last: partial would make the system fields optional again.

DeepPartial<typeof Recipe> and Committed<typeof Recipe> are the schema types these return, so Infer<Committed<typeof Recipe>> 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
<typeof
Recipe
>
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
_rev: string
} const
PartialRecipe
=
s
.
partial
(
Recipe
)
type
PartialRecipe
=
s
.
Infer
<typeof
PartialRecipe
>
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
title?: string | undefined
} // 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
<
s
.
Committed
<typeof
Recipe
>>

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 shows all four combinations side by side.