Build Sanity schemas declaratively and get typescript types of schema values for free!
- Typescript types for Sanity Values!
- ALL types are inferred! No messing with generics, awkward casting, or code generation.
- Zod schemas for parsing & transforming values (most notably,
datetime
values into javascriptDate
)! - Automatically generated mocks for testing!
- Support for any additional types!
npm install sanity-typed-schema-builder sanity
import { s } from "sanity-typed-schema-builder";
const foo = s.document({
name: "foo",
fields: [
{
name: "foo",
type: s.string(),
},
{
name: "bar",
type: s.array({
of: [s.datetime(), s.number({ readOnly: true })],
}),
},
{
name: "hello",
optional: true,
type: s.object({
fields: [
{
name: "world",
type: s.number(),
},
],
}),
},
],
});
// Use schemas in Sanity
export default createSchema({
name: "default",
types: [foo.schema()],
});
Your sanity client's return values can be typed with s.infer
:
import sanityClient from "@sanity/client";
const client = sanityClient(/* ... */);
// results are automatically typed from the schema!
const result: s.infer<typeof foo> = await client.fetch(`* [_type == "foo"][0]`);
/**
* typeof result === {
* _createdAt: string;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* bar: (string | number)[];
* foo: string;
* hello?: {
* world: number;
* };
* };
**/
Because sanity returns JSON values, some values require conversion (ie changing most date strings into Date
s). This is available with .parse
:
const parsedValue: s.output<typeof foo> = foo.parse(result);
/**
* typeof parsedValue === {
* _createdAt: Date;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: Date;
* bar: (Date | number)[];
* foo: string;
* hello?: {
* world: number;
* };
* };
**/
Mocks that match your schema can be generated with .mock
:
// Use @faker-js/faker to create mocks for tests!
import { faker } from "@faker-js/faker";
const mock = foo.mock(faker);
/**
* Same type as s.infer<typeof foo>
*
* typeof mock === {
* _createdAt: string;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* bar: (string | number)[];
* foo: string;
* hello?: {
* world: number;
* };
* };
**/
All methods correspond to a Schema Type and pass through their corresponding Schema Type Properties as-is. For example, s.string(def)
takes the usual properties of the sanity string type. Sanity's types documentation should "just work" with these types.
The notable difference is between how the sanity schema, the type
property, and the name
/ title
/ description
property are defined. The differentiator is that the s.*
methods replace type
, not the entire field:
// This is how schemas are defined in sanity
const schema = {
type: "document",
name: "foo",
fields: [
{
name: "bar",
title: "Bar",
description: "The Bar",
type: "string",
},
],
};
// This is the corresponding type in sanity-typed-schema-builder
const type = s.document({
name: "foo",
fields: [
{
name: "bar",
title: "Bar",
description: "The Bar",
type: s.string(),
},
],
});
// INVALID!!!
const invalidType = s.document({
name: "foo",
fields: [
// This is invalid. s.string is a type, not an entire field.
s.string({
name: "bar",
title: "Bar",
description: "The Bar",
}),
],
});
The only types with names directly in the type are s.document
(because all documents are named and not nested) and s.objectNamed
(because named objects have unique behavior from nameless objects).
For types with fields
(ie s.document
, s.object
, s.objectNamed
, s.file
, and s.image
) all fields
are required by default (rather than sanity's default, which is optional by default). You can set it to optional: true
.
const type = s.object({
fields: [
{
name: "foo",
type: s.number(),
},
{
name: "bar",
optional: true,
type: s.number(),
},
],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* foo: number;
* bar?: number;
* }
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* foo: number;
* bar?: number;
* }
*/
const schema = type.schema();
/**
* const schema = {
* type: "object",
* fields: [
* {
* name: "foo",
* type: "number",
* validation: (Rule) => Rule.validation(),
* },
* {
* name: "bar",
* type: "number",
* },
* ],
* };
*/
All array type properties pass through with the exceptions noted in Types.
Other exceptions include min
, max
, and length
. These values are used in the zod validations, the sanity validations, and the inferred types.
const type = s.array({
of: [s.boolean(), s.datetime()],
});
type Value = s.infer<typeof type>;
/**
* type Value === (boolean | string)[];
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === (boolean | Date)[];
*/
const schema = type.schema();
/**
* const schema = {
* type: "array",
* of: [{ type: "boolean" }, { type: "datetime" }],
* ...
* };
*/
const type = s.array({
min: 1,
of: [s.boolean()],
});
type Value = s.infer<typeof type>;
/**
* type Value === [boolean, ...boolean[]];
*/
const type = s.array({
max: 2,
of: [s.boolean()],
});
type Value = s.infer<typeof type>;
/**
* type Value === [] | [boolean] | [boolean, boolean];
*/
const type = s.array({
min: 1,
max: 2,
of: [s.boolean()],
});
type Value = s.infer<typeof type>;
/**
* type Value === [boolean] | [boolean, boolean];
*/
const type = s.array({
length: 3,
of: [s.boolean()],
});
type Value = s.infer<typeof type>;
/**
* type Value === [boolean, boolean, boolean];
*/
All block type properties pass through with the exceptions noted in Types.
const type = s.block();
type Value = s.infer<typeof type>;
/**
* type Value === PortableTextBlock;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === PortableTextBlock;
*/
const schema = type.schema();
/**
* const schema = {
* type: "block",
* ...
* };
*/
All boolean type properties pass through with the exceptions noted in Types.
const type = s.boolean();
type Value = s.infer<typeof type>;
/**
* type Value === boolean;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === boolean;
*/
const schema = type.schema();
/**
* const schema = {
* type: "boolean",
* ...
* };
*/
All date type properties pass through with the exceptions noted in Types.
const type = s.date();
type Value = s.infer<typeof type>;
/**
* type Value === string;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === string;
*/
const schema = type.schema();
/**
* const schema = {
* type: "date",
* ...
* };
*/
All datetime type properties pass through with the exceptions noted in Types.
Other exceptions include min
and max
. These values are used in the zod validations and the sanity validations.
Datetime parses into a javascript Date
.
const type = s.datetime();
type Value = s.infer<typeof type>;
/**
* type Value === string;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === Date;
*/
const schema = type.schema();
/**
* const schema = {
* type: "datetime",
* ...
* };
*/
All document type properties pass through with the exceptions noted in Types and Types with Fields.
const type = s.document({
name: "foo",
fields: [
{
name: "foo",
type: s.number(),
},
{
name: "bar",
optional: true,
type: s.number(),
},
],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* _createdAt: string;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* foo: number;
* bar?: number;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _createdAt: Date;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: Date;
* foo: number;
* bar?: number;
* };
*/
const schema = type.schema();
/**
* const schema = {
* name: "foo",
* type: "document",
* fields: [...],
* ...
* };
*/
All file type properties pass through with the exceptions noted in Types and Types with Fields.
const type = s.file({
fields: [
{
name: "foo",
type: s.number(),
},
{
name: "bar",
optional: true,
type: s.number(),
},
],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* _type: "file";
* asset: {
* _type: "reference";
* _ref: string;
* };
* foo: number;
* bar?: number;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _type: "file";
* asset: {
* _type: "reference";
* _ref: string;
* };
* foo: number;
* bar?: number;
* };
*/
const schema = type.schema();
/**
* const schema = {
* name: "foo",
* type: "file",
* fields: [...],
* ...
* };
*/
All geopoint type properties pass through with the exceptions noted in Types.
const type = s.geopoint();
type Value = s.infer<typeof type>;
/**
* type Value === {
* _type: "geopoint";
* alt: number;
* lat: number;
* lng: number;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _type: "geopoint";
* alt: number;
* lat: number;
* lng: number;
* };
*/
const schema = type.schema();
/**
* const schema = {
* type: "geopoint",
* ...
* };
*/
All image type properties pass through with the exceptions noted in Types and Types with Fields.
Other exceptions include hotspot
. Including hotspot: true
adds the crop
and hotspot
properties in the infer types.
const type = s.image({
fields: [
{
name: "foo",
type: s.number(),
},
{
name: "bar",
optional: true,
type: s.number(),
},
],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* _type: "image";
* asset: {
* _type: "reference";
* _ref: string;
* };
* foo: number;
* bar?: number;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _type: "image";
* asset: {
* _type: "reference";
* _ref: string;
* };
* foo: number;
* bar?: number;
* };
*/
const schema = type.schema();
/**
* const schema = {
* name: "foo",
* type: "image",
* fields: [...],
* ...
* };
*/
All number type properties pass through with the exceptions noted in Types.
Other exceptions include greaterThan
, integer
, lessThan
, max
, min
, negative
, positive
, and precision
. These values are used in the zod validations and the sanity validations.
const type = s.number();
type Value = s.infer<typeof type>;
/**
* type Value === number;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === number;
*/
const schema = type.schema();
/**
* const schema = {
* type: "number",
* ...
* };
*/
All object type properties pass through with the exceptions noted in Types and Types with Fields.
const type = s.object({
fields: [
{
name: "foo",
type: s.number(),
},
{
name: "bar",
optional: true,
type: s.number(),
},
],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* foo: number;
* bar?: number;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* foo: number;
* bar?: number;
* };
*/
const schema = type.schema();
/**
* const schema = {
* name: "foo",
* type: "object",
* fields: [...],
* ...
* };
*/
All object type properties pass through with the exceptions noted in Types and Types with Fields.
This is separate from s.object
because, when objects are named in sanity, there are significant differences:
- The value has a
_type
field equal to the object's name. - They can be used directly in schemas (like any other schema).
- They can also be registered as a top level object and simply referenced by type within another schema.
const type = s.objectNamed({
name: "aNamedObject",
fields: [
{
name: "foo",
type: s.number(),
},
{
name: "bar",
optional: true,
type: s.number(),
},
],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* _type: "aNamedObject";
* foo: number;
* bar?: number;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _type: "aNamedObject";
* foo: number;
* bar?: number;
* };
*/
const schema = type.schema();
/**
* const schema = {
* name: "foo",
* type: "object",
* fields: [...],
* ...
* };
*/
// Use `.ref()` to reference it in another schema.
const someOtherType = s.array({ of: [type.ref()] });
// The reference value is used directly.
type SomeOtherValue = s.infer<typeof someOtherType>;
/**
* type SomeOtherValue = [{
* _type: "aNamedObject";
* foo: number;
* bar?: number;
* }];
*/
// The schema is made within the referencing schema
const someOtherTypeSchema = someOtherType.schema();
/**
* const someOtherTypeSchema = {
* type: "array",
* of: [{ type: "" }],
* ...
* };
*/
createSchema({
name: "default",
types: [type.schema(), someOtherType.schema()],
});
All reference type properties pass through with the exceptions noted in Types.
Reference resolves into the referenced document's mock.
Other exceptions include weak
. Including weak: true
adds the _weak: true
properties in the infer types.
const type = s.reference({
to: [someDocumentType, someOtherDocumentType],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* _ref: string;
* _type: "reference";
* _weak?: boolean;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _ref: string;
* _type: "reference";
* _weak?: boolean;
* };
*/
const schema = type.schema();
/**
* const schema = {
* type: "reference",
* to: [...],
* ...
* };
*/
const type = s.reference({
weak: true,
to: [someDocumentType, someOtherDocumentType],
});
type Value = s.infer<typeof type>;
/**
* type Value === {
* _ref: string;
* _type: "reference";
* _weak: true;
* };
*/
All slug type properties pass through with the exceptions noted in Types.
Slug parses into a string.
const type = s.slug();
type Value = s.infer<typeof type>;
/**
* type Value === {
* _type: "slug";
* current: string;
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === string;
*/
const schema = type.schema();
/**
* const schema = {
* type: "slug",
* ...
* };
*/
All string type properties pass through with the exceptions noted in Types.
Other exceptions include min
, max
, and length
. These values are used in the zod validations and the sanity validations.
const type = s.string();
type Value = s.infer<typeof type>;
/**
* type Value === string;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === string;
*/
const schema = type.schema();
/**
* const schema = {
* type: "string",
* ...
* };
*/
All text type properties pass through with the exceptions noted in Types.
Other exceptions include min
, max
, and length
. These values are used in the zod validations and the sanity validations.
const type = s.text();
type Value = s.infer<typeof type>;
/**
* type Value === string;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === string;
*/
const schema = type.schema();
/**
* const schema = {
* type: "text",
* ...
* };
*/
All url type properties pass through with the exceptions noted in Types.
const type = s.url();
type Value = s.infer<typeof type>;
/**
* type Value === string;
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === string;
*/
const schema = type.schema();
/**
* const schema = {
* type: "url",
* ...
* };
*/
In addition to the default sanity schema types, you may have nonstandard types (custom asset sources like MUX Input or unique inputs like code input).
s.createType
allows for creation of a custom type. It returns an object of type s.SanityType<Definition, Value, ParsedValue, ResolvedValue>
. All provided s.*
methods use this, so it should be fully featured for any use case.
An example using Mux Input (not including installing the plugin):
import { faker } from "@faker-js/faker";
import { s } from "sanity-typed-schema-builder";
import { z } from "zod";
const muxVideo = () =>
s.createType({
// `schema` returns the sanity schema type
schema: () => ({ type: "mux.video" } as const),
// `mock` returns an instance of the native sanity value
// `faker` will have a stable `seed` value
mock: (faker) =>
({
_type: "mux.video",
asset: {
_type: "reference",
_ref: faker.datatype.uuid(),
},
} as const),
// `zod` is used for parsing this type
zod: z.object({
_type: z.literal("mux.video"),
asset: z.object({
_type: z.literal("reference"),
_ref: z.string(),
}),
}),
// `zodResolved` is used for parsing into the resolved value
// defaults to reusing `zod`
zodResolved: z
.object({
_type: z.literal("mux.video"),
asset: z.object({
_type: z.literal("reference"),
_ref: z.string(),
}),
})
.transform(
({ asset: { _ref: playbackId } }) => resolvedValues[playbackId]
),
});
const type = document({
name: "foo",
fields: [
{
name: "video",
type: muxVideo(),
},
],
});
const value = type.mock(faker);
/**
* typeof value === {
* _createdAt: string;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* video: {
* _type: "mux.video";
* asset: {
* _ref: string;
* _type: "reference";
* };
* };
* };
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* typeof parsedValue === {
* _createdAt: Date;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: Date;
* video: {
* _type: "mux.video";
* asset: {
* _ref: string;
* _type: "reference";
* };
* };
* };
*/
const resolvedValue: s.resolved<typeof type> = type.resolve(value);
/**
* typeof resolvedValue === {
* _createdAt: Date;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: Date;
* video: (typeof resolvedValues)[string];
* };
*/
const schema = type.schema();
/**
* const schema = {
* name: "foo",
* type: "document",
* fields: [
* {
* name: "video",
* type: "mux.video",
* },
* ],
* };
*/
Due to sanity's transport layer being JSON (and whatever reason slug
has for being wrapped in an object), some of sanity's return values require some transformation in application logic. Every type includes a .parse(value)
method that transforms values to a more convenient value.
We accomplish that using Zod, a powerful schema validation library with full typescript support. A few of the types have default transformations (most notably s.datetime
parsing into a javascript Date
object). The zod types are available for customization, allowing your own transformations.
const type = s.document({
name: "foo",
// If you dislike the dangling underscore on `_id`, this transforms it to `id`:
zod: (zod) => zod.transform(({ _id: id, ...doc }) => ({ id, ...doc })),
fields: [
{
name: "aString",
type: s.string(),
},
{
name: "aStringLength",
type: s.string({
// For whatever reason, if you want the length of the string instead of the string itself:
zod: (zod) => zod.transform((value) => value.length),
}),
},
{
name: "aDateTime",
type: s.datetime(),
},
{
name: "aSlug",
type: s.slug(),
},
],
});
const value: type Value === {
/* ... */
};
/**
* This remains the same:
*
* typeof value === {
* _createdAt: string;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* aString: string;
* aStringLength: string;
* aDateTime: string;
* aSlug: {
* _type: "slug";
* current: string;
* };
* }
*/
const parsedValue: s.output<typeof type> = type.parse(value);
/**
* Notice the changes:
*
* typeof parsedValue === {
* _createdAt: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* id: string;
* aString: string;
* aStringLength: number;
* aDateTime: Date;
* aSlug: string;
* }
*/
Sanity values are used directly in react components or application code that needs to be tested. While tests tend to need mocks that are specific to isolated tests, autogenerated mocks are extremely helpful. Every type includes a .mock(faker)
method that generates mocks of that type.
We accomplish that using Faker, a powerful mocking library with full typescript support. All of the types have default mocks. The mock methods are available for customization, allowing your own mocks.
Note: Each type will create it's own instance of Faker
with a seed
based on it's path in the document, so mocked values for any field should remain consistent as long as it remains in the same position.
import { faker } from "@faker-js/faker";
const type = s.document({
name: "foo",
fields: [
{
name: "aString",
type: s.string(),
},
{
name: "aFirstName",
type: s.string({
mock: (faker) => faker.name.firstName(),
}),
},
],
});
const value = type.mock(faker);
/**
* typeof value === {
* _createdAt: string;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: string;
* aString: string;
* aFirstName: string;
* }
*
* value.aString === "Seamless"
* value.aFirstName === "Katelynn"
*/
Sanity values often reference something outside of itself, most notably s.reference
referencing other documents. Applications determine how those resolutions happen (in the case of s.reference
, usually via groq queries) but tests that require resolved values shouldn't rebuild that logic. Every type includes a .resolve(value)
method that resolves mocks of that type.
We accomplish that using Zod, a powerful schema validation library with full typescript support. All of the types have default resolutions. The resolution methods are available for customization, allowing your own resolution.
import { faker } from "@faker-js/faker";
const barType = s.document({
name: "bar",
fields: [
{
name: "value",
type: s.string(),
},
],
});
const nonSanityMocks: Record<string, NonSanity> = {
/* ... */
};
const type = s.document({
name: "foo",
fields: [
{
name: "bar",
type: s.reference({ to: [barType] }),
},
{
name: "aString",
type: s.string(),
},
{
name: "nonSanity",
type: s.string({
zodResolved: (zod) => zod.transform((value) => nonSanityMocks[value]!),
}),
},
],
});
const value = type.resolve(type.mock(faker));
/**
* typeof value === {
* _createdAt: Date;
* _id: string;
* _rev: string;
* _type: "foo";
* _updatedAt: Date;
* bar: {
* _createdAt: Date;
* _id: string;
* _rev: string;
* _type: "bar";
* _updatedAt: Date;
* value: string;
* };
* aString: string;
* nonSanity: NonSanity;
* }
*/