
Using defineType and defineField in Sanity
defineType and defineField are helper functions exported from the sanity package that wrap your schema definitions and give you TypeScript autocompletion and type checking while you write them. You wrap each document or object type in defineType(), each field in defineField(), and each item inside an array's of list in defineArrayMember(). At runtime they simply return the object you pass in, so they change nothing about how your Studio behaves. Their value is entirely in the editor: invalid properties, misspelt options and wrong validation methods are flagged before you ever run the Studio.
Sanity schemas are plain JavaScript objects, which makes them flexible but easy to get subtly wrong. A typo like option instead of options is silently ignored, and you only notice when the slug button doesn't appear. The define helpers catch those mistakes as you type. This article explains what each helper does, how type narrowing works, how to use the strict and aliasFor options, how the helpers relate to Sanity TypeGen, and the patterns that keep a growing schema tidy.
What the Helpers Actually Do
There are three helpers, and they all follow the same idea:
| Helper | Wraps | Typical location |
|---|---|---|
defineType() | A whole schema type: a document, object or alias type | The default or named export of a schema file |
defineField() | A single field inside fields | Inside a defineType() call |
defineArrayMember() | A single item type inside an array's of | Inside an array field |
Each one is an identity function with very detailed type signatures. Call defineField({ type: 'slug' }) and TypeScript knows that options may contain source, maxLength, slugify and isUnique, and that validation receives a SlugRule. Change the type to 'image' and the available options switch to hotspot, accept and friends.
You can confirm that nothing happens at runtime by logging the result: the object that comes out is the object that went in. That means you can adopt the helpers gradually, mixing wrapped and unwrapped definitions in the same project without breaking anything.
A Basic Example
Here is a post document written with all three helpers. If you haven't built a schema yet, start with how to create your first schema in Sanity and come back.
// schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (rule) => rule.required().max(90),
}),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }],
}),
defineField({
name: 'tags',
type: 'array',
of: [defineArrayMember({ type: 'string' })],
options: { layout: 'tags' },
}),
defineField({
name: 'body',
type: 'array',
of: [
defineArrayMember({ type: 'block' }),
defineArrayMember({ type: 'image', options: { hotspot: true } }),
],
}),
],
})
Then register it in your schema index and config:
// schemaTypes/index.ts
import { author } from './author'
import { post } from './post'
export const schemaTypes = [post, author]
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './schemaTypes'
export default defineConfig({
name: 'default',
title: 'My Studio',
projectId: 'your-project-id',
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
})
Notice that defineConfig follows the same pattern for the Studio configuration as a whole. The post on Sanity project structure explains where each of these files usually lives.
How Type Narrowing Works
The helpers narrow their accepted properties based on the type string. This is the core reason to use them.
Options Are Checked Per Type
Without the helpers, this mistake compiles and runs:
{
name: 'slug',
type: 'slug',
option: { source: 'title' }, // typo: should be "options"
}
Wrapped in defineField(), TypeScript reports that option doesn't exist on the slug definition. The same applies to nonsense combinations, such as putting hotspot: true in the options of a string field.
Validation Rules Are Typed
The rule argument in validation is narrowed too. A string field gets StringRule, which has min, max, length, regex, uppercase and lowercase. An array field gets ArrayRule, with min, max, length and unique. Calling rule.unique() on a string field is a type error, which saves you from rules that silently do nothing. The full set of rules is covered in how to add validation rules to Sanity fields.
Initial Values Are Typed
initialValue is checked against the field's value type. Set initialValue: 42 on a boolean field and you'll see an error straight away.
Required Properties Are Enforced
A field without a name, or an array without of, is flagged. A reference field without to is flagged. These are the kinds of mistakes the Studio would otherwise report at startup with a schema error, so catching them in the editor shortens the feedback loop.
Why defineArrayMember Matters
It's tempting to wrap fields and skip the array members, but defineArrayMember() is where a lot of mistakes hide. Array members are full type definitions in their own right, with their own options, fields, preview and validation. Inline objects inside arrays are especially common:
defineField({
name: 'links',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'link',
fields: [
defineField({ name: 'label', type: 'string' }),
defineField({ name: 'url', type: 'url' }),
],
preview: {
select: { title: 'label', subtitle: 'url' },
},
}),
],
})
Wrapping the member means the nested fields, preview.select and object options all get checked. Array members also allow an optional name, which becomes the _type stored on each item. Naming inline objects is good practice because it makes GROQ filters such as links[_type == "link"] readable. See working with arrays in Sanity schemas for more on array design.
Custom Properties and the strict Option
The helpers are strict by default: unknown properties are errors. That's what catches typos, but it gets in the way when a plugin reads its own options from your schema. The @sanity/document-internationalization plugin, for example, reads options.documentInternationalization.
You have two ways to handle this.
Turn Off Strict Checking for One Definition
Pass strict: false as the second argument:
defineField(
{
name: 'title',
type: 'string',
options: {
documentInternationalization: { exclude: true },
},
},
{ strict: false },
)
This is quick but switches off checking for the whole definition, so use it sparingly.
Extend the Types With Declaration Merging
The cleaner option is to teach TypeScript about the extra property once, using declaration merging:
// sanity.d.ts
import 'sanity'
declare module 'sanity' {
interface StringOptions {
documentInternationalization?: { exclude?: boolean }
}
}
Now the option is accepted and checked everywhere, with no strict: false needed. Many plugins ship these augmentations themselves, so check a plugin's README before writing your own.
Using aliasFor With Custom Types
When you reference a custom type by name, such as an seo object type you defined elsewhere, TypeScript can't know which properties that type accepts. The helpers fall back to a permissive shape. If you want proper checking, tell them which built-in type the custom type is based on:
defineField(
{
name: 'seo',
type: 'seo',
options: { collapsible: true, collapsed: true },
},
{ aliasFor: 'object' },
)
With aliasFor: 'object', the options are checked as object options. This is optional, and most projects only bother with it for heavily reused types. The post on building reusable object types in Sanity shows how to create those types in the first place.
The Helpers Don't Type Your Content
This is the most common misunderstanding. defineType() types the schema definition, not the documents stored in the Content Lake. If you fetch a post with @sanity/client, the result is not automatically typed as a post.
For content types, use Sanity TypeGen, which reads your schema and your GROQ queries and generates TypeScript types for both:
npx sanity schema extract
npx sanity typegen generate
The first command writes a schema.json file describing your schema. The second reads it, scans your code for queries and writes sanity.types.ts. To get typed query results, wrap queries in defineQuery:
// src/lib/queries.ts
import { defineQuery } from 'next-sanity'
export const postBySlugQuery = defineQuery(`
*[_type == "post" && slug.current == $slug][0]{
title,
"slug": slug.current,
"authorName": author->name,
tags
}
`)
After running TypeGen you get a PostBySlugQueryResult type, and when sanity.types.ts is included by your tsconfig.json, client.fetch(postBySlugQuery, { slug }) infers that return type automatically. Typegen configuration belongs in the typegen block of sanity.cli.ts; the older standalone sanity-typegen.json file is deprecated.
The two tools complement each other:
- Define helpers: check that your schema definitions are valid while you write them.
- TypeGen: turns that schema and your queries into types for the front end.
Patterns for Larger Schemas
A few habits make the helpers more useful as a project grows.
- One type per file: export each
defineType()from its own file and collect them inschemaTypes/index.ts. It keeps diffs small and makes types easy to find. - Named exports:
export const post = defineType(...)is easier to search for and refactor than a default export. - Shared field factories: when several types need the same field, write a function that returns
defineField()output, rather than copying the definition. - Use icons:
defineType()accepts aniconproperty, which appears in the Studio's lists and menus and helps editors scan content. - Keep previews explicit: add a
previewblock to each document type so lists show meaningful titles and subtitles.
A shared field factory looks like this:
// schemaTypes/fields/seoTitle.ts
import { defineField } from 'sanity'
export const seoTitleField = (group?: string) =>
defineField({
name: 'seoTitle',
title: 'SEO title',
type: 'string',
group,
validation: (rule) => rule.max(60).warning('Keep SEO titles under 60 characters'),
})
Use it in any type with fields: [seoTitleField('seo'), ...]. Because the factory returns a defineField() result, the definition stays fully typed wherever it's used. The group argument ties into field groups, which are covered in how to organise fields with groups and fieldsets.
Migrating an Existing Schema
If your schema predates the helpers, converting it is mechanical:
- Import the helpers at the top of each schema file:
import { defineType, defineField, defineArrayMember } from 'sanity'. - Wrap the outer object of each type in
defineType(). - Wrap each item in
fieldsindefineField(). - Wrap each item in
ofindefineArrayMember(). - Run
npx tsc --noEmitand fix the errors that appear. Each one is a mistake that was previously ignored.
Step 5 is where the helpers prove themselves. It's common to find dead options, wrong option names and validation calls that never had any effect.
FAQ: defineType and defineField
No. They are identity functions that return exactly the object you pass in. They exist only to give TypeScript enough information to autocomplete and check your schema definitions.
Yes. They work in plain JavaScript files too, and editors such as VS Code still use their type information to offer autocompletion. You just won't get build-time type errors unless you run TypeScript checks.
It's strongly recommended. Array members are full type definitions with their own options, fields and previews, and without the wrapper those properties aren't checked.
The helpers are strict by default, so unknown properties are errors. Either pass strict: false as the second argument, or extend the relevant options interface with TypeScript declaration merging so the property is recognised everywhere.
No. They type the schema definition, not the content. Use Sanity TypeGen, with sanity schema extract and sanity typegen generate, to generate types for documents and GROQ query results.
When a field uses a custom type name, TypeScript can't tell which properties it accepts. Setting aliasFor to the built-in type it's based on, such as object, lets the helpers check its options correctly.
Conclusion
defineType, defineField and defineArrayMember are small helpers with a large payoff. They cost nothing at runtime, but they turn your schema from loosely checked objects into fully typed definitions, so misspelt options, invalid validation rules and missing required properties are caught as you type rather than when an editor reports a broken field.
Wrap every type, field and array member, use declaration merging when plugins add their own options, and pair the helpers with TypeGen so the content you query is typed too. Together they give you type safety from the schema file all the way to the component that renders the page.


