
Using Sanity TypeGen for Type-Safe Queries
Sanity TypeGen is a CLI tool that generates TypeScript types from your Studio schema and from the results of your GROQ queries. You run npx sanity schemas extract to write a schema.json file, then npx sanity typegen generate to produce sanity.types.ts. If you write your queries with defineQuery, the Sanity client picks up those types automatically, so client.fetch(POST_QUERY) returns a fully typed result without any manual generics.
GROQ lets you reshape data freely, which is great until a projection drifts away from the TypeScript interface you wrote by hand. TypeGen removes that gap: the types come from the schema and the query itself, so renaming a field or forgetting a null check shows up as a compile error instead of a broken page. This guide covers how TypeGen works, setting it up in a Studio, a separate frontend and a monorepo, writing queries it can read, handling null and required fields, the utility types it ships, and how to keep generated files in step with your schema.
How TypeGen Works
TypeGen runs in two steps, and it helps to know what each one does:
| Step | Command | Input | Output |
|---|---|---|---|
| 1. Extract the schema | sanity schemas extract | sanity.config.ts and your schema types | schema.json |
| 2. Generate types | sanity typegen generate | schema.json and your source files | sanity.types.ts |
The extract step loads your Studio configuration and writes a static JSON description of every document, object and field type. The generate step reads that file, scans your source code for GROQ queries, and "overlays" the schema on each query to work out the shape of its result. You get two kinds of types:
- Schema types: one type per document and object type, such as
Post,AuthorandSeo. Useful in Studio customisations, migration scripts and anywhere you work with whole documents. - Query result types: one type per query, such as
POST_QUERY_RESULT, describing exactly what that projection returns.
TypeGen never runs your queries or contacts your dataset. Everything happens locally from source files, which makes it fast and safe to run in CI.
Requirements
You need a current Studio. The docs list Sanity Studio v6.6.0 or later for the integrated TypeGen features, and the CLI needs Node.js 22.12 or later. Your queries must be:
- Assigned to a variable: inline queries passed straight to
fetch()are ignored. - Uniquely named: the variable name must be unique across all scanned files, because it becomes the type name.
- Written with
defineQueryor thegroqtag:defineQueryis exported bygroq,next-sanityand@sanity/sveltekit. - Valid GROQ: queries that don't parse are skipped with an error.
If you're new to the CLI itself, the Sanity CLI commands guide covers installation and authentication.
Generate Your First Types
Start with a simple schema type:
// schemaTypes/post.ts
import { defineField, defineType } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({
name: 'status',
type: 'string',
options: { list: ['draft', 'review', 'live'], layout: 'radio' },
}),
defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
defineField({ name: 'publishedAt', type: 'datetime' }),
],
})
From the Studio project root (where sanity.config.ts lives), run both commands:
npx sanity schemas extract
npx sanity typegen generate
The output looks like this:
✔ Extracted schema to /Users/you/studio/schema.json
✔ Config loaded from sanity.cli.ts
✔ Schema loaded from ./schema.json
✔ Successfully generated types to ./sanity.types.ts
└─ 1 query and 9 schema types
Open sanity.types.ts and you'll find a Post type with the built-in fields (_id, _type, _createdAt, _updatedAt, _rev) marked as required, your own fields marked optional, and the status field typed as the literal union 'draft' | 'review' | 'live' because it uses options.list.
Configure TypeGen in sanity.cli.ts
TypeGen configuration lives in a typegen block in sanity.cli.ts. The older sanity-typegen.json file is deprecated, so move any existing settings across. Here are the options with their default values:
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: 'your-project-id',
dataset: 'production',
},
typegen: {
path: [
'./src/**/*.{ts,tsx,js,jsx,mjs,cjs,astro,vue,svelte}',
'./app/**/*.{ts,tsx,js,jsx,mjs,cjs,astro,vue,svelte}',
'./sanity/**/*.{ts,tsx,js,jsx,mjs,cjs}',
],
schema: './schema.json',
generates: './sanity.types.ts',
overloadClientMethods: true,
},
})
What each option does:
- path: one glob or an array of globs pointing at the files that contain your queries. TypeGen understands
.ts,.tsx,.js,.jsx,.mjs,.cjs,.astro,.svelteand.vuefiles. - schema: where to read the extracted schema from.
- generates: where to write the generated types.
- overloadClientMethods: when
true, TypeGen adds type overloads soclient.fetch()returns the right type for eachdefineQueryquery. Set it tofalseif you'd rather pass generics yourself.
Automatic Generation During Development
You can also ask the CLI to extract the schema and generate types whenever you run sanity dev or sanity build:
// sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
schemaExtraction: { enabled: true },
typegen: { enabled: true },
})
This suits a Studio project or an embedded Studio, because the same sanity dev process is running anyway. In a frontend that doesn't use sanity dev, use watch mode instead:
npx sanity typegen generate --watch
Both schemas extract and typegen generate accept --watch, and regenerate their files as you edit.
Write Queries TypeGen Can Read
Put your queries in one module and wrap each one in defineQuery:
// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'
export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]
| order(publishedAt desc)[0...12]{
_id,
title,
"slug": slug.current,
publishedAt,
"authorName": author->name
}`)
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
_id,
title,
status,
body,
"author": author->{ name, "slug": slug.current }
}`)
TypeGen follows the casing of each variable when naming the result type: POSTS_QUERY becomes POSTS_QUERY_RESULT, and a camel-case postsQuery would become PostsQueryResult. For the first query above, it generates something like:
export type POSTS_QUERY_RESULT = Array<{
_id: string
title: string | null
slug: string | null
publishedAt: string | null
authorName: string | null
}>
Notice that the dereference author->name is typed from the author schema, so you get the correct type across references. The posts on GROQ projections and dereferencing in GROQ explain the query syntax in more depth.
Use Parameters, Not String Building
TypeGen reads your source files without running them. It can follow string and number literals, shared fragments, as const objects, values imported from other files and simple arrow functions called with literal arguments. It can't follow ternaries, + concatenation, array indexes, environment variables, renamed imports or function calls with variable arguments.
The fix is almost always a GROQ parameter:
// Not resolvable: the value only exists at runtime
// const QUERY = defineQuery(`*[_type == "${process.env.DOC_TYPE}"]`)
// Resolvable: the query is static and the value is passed at fetch time
export const BY_TYPE_QUERY = defineQuery(`*[_type == $type]{ _id, _type }`)
When TypeGen can't resolve a query, it prints an error naming the variable, file and position, skips that query and still generates types for everything else. Parameters also protect you from injection, which is why the GROQ parameters guide recommends them for every runtime value.
Ignore a Query
To skip a query on purpose, add a leading comment:
// @sanity-typegen-ignore
const DEBUG_QUERY = groq`*[_type == "anything"][0]`
Use the Generated Types
With overloadClientMethods enabled and sanity.types.ts included in your tsconfig.json, typed queries just work:
// src/app/blog/page.tsx
import { client } from '@/sanity/lib/client'
import { POSTS_QUERY } from '@/sanity/queries'
export default async function BlogPage() {
const posts = await client.fetch(POSTS_QUERY)
// posts is POSTS_QUERY_RESULT: hover it in your editor to check
return (
<ul>
{posts.map((post) => (
<li key={post._id}>{post.title ?? 'Untitled'}</li>
))}
</ul>
)
}
The sanityFetch helper returned by defineLive in next-sanity infers types the same way, and returns the result on a data property. If types come back as any, check that the generated file is matched by the include array in tsconfig.json. That's the most common reason inference silently fails.
You can still import types explicitly, which is handy for component props:
import type { POST_QUERY_RESULT } from '@/sanity.types'
type Props = { post: NonNullable<POST_QUERY_RESULT> }
export function PostHeader({ post }: Props) {
return <h1>{post.title}</h1>
}
Handle null and Required Fields
By default, every field you define is optional in schema types and | null in query results. That's accurate: the Content Lake doesn't enforce your schema, and documents created before a field existed won't have it.
If you want required fields to be non-optional, extract the schema with a flag:
npx sanity schemas extract --enforce-required-fields
npx sanity typegen generate
Fields with validation: (rule) => rule.required() then become required in the generated types. Treat this as a trade-off rather than a free win:
- Drafts can be invalid: validation only blocks publishing. If you render drafts in preview, a "required" field can still be
null. - Old content may be incomplete: adding a required field doesn't backfill existing documents. A content migration can fill the gaps.
- It only reflects
required(): other rules, such as minimum lengths or custom functions, don't change the generated types.
For public pages that only show published content, enforced required fields remove a lot of ?? '' noise. For preview code paths, keep the defensive checks.
Type Utilities for Complex Schemas
Page builders and deeply nested objects produce large generated types. TypeGen provides two helpers from @sanity/codegen to pick out the parts you need:
import type { FilterByType, Get } from '@sanity/codegen'
import type { Page, PAGE_QUERY_RESULT } from '@/sanity.types'
// Reach into nested fields (up to 20 levels); use number to step into arrays
type HeroImageAlt = Get<Page, 'hero', 'image', 'alt'>
type Section = Get<Page, 'sections', number>
// Pick one member of a union by its _type
type HeroBlock = FilterByType<Section, 'hero'>
FilterByType is particularly useful for block renderers, where each component should receive only its own block type. It pairs well with the patterns in Sanity arrays and custom Portable Text blocks.
Project Layouts and Workflows
Where you run TypeGen depends on how your code is organised.
Embedded Studio
If the Studio lives inside your Next.js app, there's one project root and one sanity.cli.ts. The defaults already scan ./src, ./app and ./sanity, so usually all you need is typegen.enabled: true or a typegen script. The embedded Studio guide covers that setup.
Monorepo
Keep the Studio and frontend as separate packages, and point the Studio's TypeGen config at the frontend:
// apps/studio/sanity.cli.ts
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
typegen: {
path: '../web/src/**/*.{ts,tsx}',
schema: './schema.json',
generates: '../web/src/sanity.types.ts',
},
})
Run schemas extract and typegen generate from the Studio package, and the frontend gets fresh types.
Separate Repositories
When the frontend lives in its own repository, extract the schema in the Studio repo and copy schema.json across, for example in CI on every Studio deploy:
# In the Studio repository
npx sanity schemas extract --path ../my-frontend/schema.json --force
Then install sanity as a dev dependency in the frontend so the CLI is available, add a sanity.cli.ts with a typegen block, and run npx sanity typegen generate. Keeping sanity in devDependencies keeps it out of your runtime bundle.
Keep Types in Sync
Sanity's own starters commit both schema.json and sanity.types.ts, and regenerate them before dev and build. That way every developer and CI job starts from the same types:
{
"scripts": {
"typegen": "sanity schemas extract --force && sanity typegen generate",
"predev": "npm run typegen",
"prebuild": "npm run typegen"
}
}
The --force flag matters in CI: without it, schemas extract asks before overwriting an existing file and fails when it can't prompt.
A few habits that avoid friction:
- Merge conflicts in generated files: take either side, then regenerate. The file is rebuilt from scratch every time, so there's nothing to resolve by hand.
- Type-check in CI: run
npm run typegenand thentsc --noEmit. A schema change that breaks a query fails the build before it reaches production. - Multiple workspaces: pass
--workspace=<name>toschemas extractto choose which workspace's schema to extract.
Common Problems and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Result type is any | sanity.types.ts not in tsconfig.json include | Add the file or its folder to include |
| A query has no type | Inline query, or not written with defineQuery/groq | Assign it to a uniquely named variable |
| "Could not find binding" error | Interpolated value TypeGen can't follow | Replace with a literal or a GROQ parameter |
A field is typed unknown | Unsupported GROQ function or cross-dataset reference | Simplify the expression or cast deliberately |
| Types don't reflect a new field | Stale schema.json | Re-run schemas extract before typegen generate |
| Queries missing or clashing | Two queries share a variable name | Give every query a unique name |
Most GROQ features are supported, including filters, projections, coalesce(), select(), dereferences and array functions. Anything TypeGen doesn't understand is typed as unknown rather than guessed, which is the safe default.
FAQ: Sanity TypeGen
Yes. TypeGen is part of the Sanity CLI and works on every plan. It runs locally against your source files and an extracted schema, so it doesn't use API requests or count toward any quota.
Only when the schema changes. TypeGen reads schema.json, so a stale file produces stale types. Running both commands together in a typegen script, or enabling automatic extraction in sanity.cli.ts, removes the need to remember.
Sanity recommends committing both schema.json and sanity.types.ts so every environment has the same types without running the CLI first. If you ignore them instead, every CI job and teammate must generate them before type-checking.
Because the Content Lake doesn't enforce your schema, so any field might be missing on a given document. Extract the schema with the enforce-required-fields flag to make fields with a required validation rule non-nullable, but keep checks in preview code where drafts can be invalid.
No. TypeGen generates types from GROQ queries and Studio schemas. If you use Sanity's GraphQL API, Sanity recommends standard GraphQL tooling such as GraphQL Code Generator pointed at your GraphQL endpoint.
Yes. It parses queries from .astro, .svelte and .vue files as well as TypeScript and JavaScript, as long as the file matches the path globs in your typegen configuration.
Conclusion
Sanity TypeGen turns your schema and GROQ queries into TypeScript types with two commands: extract the schema, then generate. Configure it in the typegen block of sanity.cli.ts, write every query with defineQuery in a uniquely named variable, use parameters for runtime values, and the client returns typed results without any manual interfaces.
The real benefit appears over time. When someone renames a field or changes a reference, the type checker points at every query and component that needs updating. Add the typegen script to predev and prebuild, run tsc in CI, and your front end and content model can evolve together without silent breakages.


