Type something to search...
Using Sanity TypeGen for Type-Safe Queries

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:

StepCommandInputOutput
1. Extract the schemasanity schemas extractsanity.config.ts and your schema typesschema.json
2. Generate typessanity typegen generateschema.json and your source filessanity.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, Author and Seo. 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 defineQuery or the groq tag: defineQuery is exported by groq, next-sanity and @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, .svelte and .vue files.
  • schema: where to read the extracted schema from.
  • generates: where to write the generated types.
  • overloadClientMethods: when true, TypeGen adds type overloads so client.fetch() returns the right type for each defineQuery query. Set it to false if 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 typegen and then tsc --noEmit. A schema change that breaks a query fails the build before it reaches production.
  • Multiple workspaces: pass --workspace=<name> to schemas extract to choose which workspace's schema to extract.

Common Problems and Fixes

SymptomLikely causeFix
Result type is anysanity.types.ts not in tsconfig.json includeAdd the file or its folder to include
A query has no typeInline query, or not written with defineQuery/groqAssign it to a uniquely named variable
"Could not find binding" errorInterpolated value TypeGen can't followReplace with a literal or a GROQ parameter
A field is typed unknownUnsupported GROQ function or cross-dataset referenceSimplify the expression or cast deliberately
Types don't reflect a new fieldStale schema.jsonRe-run schemas extract before typegen generate
Queries missing or clashingTwo queries share a variable nameGive 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper