Typing GROQ Queries with Sanity TypeGen

Within Sanity Studio Customization, this guide sets up Sanity TypeGen, which generates TypeScript types from the Studio schema and from every GROQ query in the frontend. Because GROQ projections reshape documents, types for the documents alone are not enough; TypeGen analyses each query and produces a result type that matches its projection, including renamed fields, dereferenced references and possible nulls.

Without generated types, GROQ results are any or hand-written interfaces that drift from both the schema and the query. A renamed field in the schema, or a projection edited in one place but not in its interface, becomes a runtime error on a page nobody tested. With TypeGen, both kinds of change become type errors in the editor and in CI.

The TypeGen pipelinesanity schema extract writes schema.json from the Studio; sanity typegen generate reads the schema and scans the frontend for queries defined with defineQuery, writing sanity.types.ts with document types and a result type per query, which the frontend imports.Studio schemaTypeScriptschema extractschema.jsondefineQueryin frontendtypegen generatesanity.types.tsresult types
Two commands, one generated file, and every query is typed.

The Problem

A retailer’s frontend had 60 GROQ queries with hand-written interfaces. When the product schema’s price changed from a number to an object with amount and currency, the Studio change was reviewed and deployed, but eleven queries still projected price and their interfaces still said number. Product cards showed “[object Object]” in production for an hour until someone noticed. The team had been planning to write tests for every query; generated types made most of those tests unnecessary.

How TypeGen Works

Schema extraction. sanity schema extract runs in the Studio project and writes a JSON representation of all document and object types, including field types and references.

Query discovery. sanity typegen generate scans configured source files for queries wrapped in defineQuery (or assigned to variables with the groq template tag in older setups), parses each GROQ query and evaluates its projection against the schema.

Generated types. The output file contains a type per schema type and a result type per query, named after the query variable, plus a query map that clients can use to infer result types automatically.

Typed fetching. With next-sanity or @sanity/client, passing a query defined with defineQuery returns the generated result type without manual generics.

What TypeGen catchesKinds of mistakes and whether generated types catch them at compile time: renamed fields, changed field types, missing dereferences, possible nulls from missing references and invalid GROQ syntax.MistakeCaught at compile time?Field renamed in the schemayes, projection returns null or errors in usageField type changedyesReference not dereferencedyes, type is a reference objectReference can be missingyes, result includes nullGROQ syntax erroryes, generation fails
Most integration bugs between schema and queries become compile errors.

Implementation

In a monorepo with studio/ and web/ packages, configure TypeGen in the frontend with paths to the extracted schema and the query files.

JSON
// web/sanity-typegen.json
{
  "path": "./src/**/*.{ts,tsx}",
  "schema": "../studio/schema.json",
  "generates": "./src/sanity/sanity.types.ts",
  "overloadClientMethods": true
}
JSON
// package.json scripts (root)
{
  "scripts": {
    "typegen": "cd studio && sanity schema extract --path schema.json && cd ../web && sanity typegen generate",
    "typecheck": "npm run typegen && tsc -p web --noEmit"
  }
}

Queries use defineQuery, and the client infers their result types.

TypeScript
// web/src/sanity/queries.ts
import { defineQuery } from "next-sanity";

export const PRODUCT_CARD_QUERY = defineQuery(`*[_type == "product" && defined(slug.current)] | order(_createdAt desc)[0...12]{
  _id,
  name,
  "slug": slug.current,
  price { amount, currency },
  "image": images[0]{ alt, "url": asset->url }
}`);
TSX
// web/src/app/products/page.tsx
import { client } from "@/sanity/client";
import { PRODUCT_CARD_QUERY } from "@/sanity/queries";

export default async function Products() {
  const products = await client.fetch(PRODUCT_CARD_QUERY); // typed as PRODUCT_CARD_QUERYResult
  return products.map((p) => (
    <a key={p._id} href={`/products/${p.slug}`}>
      {p.name} · {p.price ? new Intl.NumberFormat("en", { style: "currency", currency: p.price.currency ?? "EUR" }).format(p.price.amount ?? 0) : "—"}
    </a>
  ));
}

If price changes shape again, p.price.amount becomes a type error the moment types are regenerated.

Running TypeGen in CI

Add a CI step that regenerates types and fails if the generated file differs from the committed one, then runs the type checker. That catches both an outdated committed file and code that no longer compiles against the current schema. Run the same step when the Studio schema changes in a pull request, so a breaking schema change cannot be merged while frontend queries depend on the old shape.

Working with generated types day to day

Generated types change the way developers write queries. Instead of writing a query and then an interface, developers write the query, run TypeGen, and let the editor suggest the result’s fields. Keep a watch script running during development that regenerates types when query files change, so feedback is immediate. When a query’s result type contains unions or nulls that the page does not expect, the right fix is usually in the query, for example filtering out documents without a slug with defined(slug.current) so the result type no longer allows a missing slug, rather than casting in the component. Treat the generated file as read-only; changes to types come from changes to queries or the schema, never from editing the output. When reviewing pull requests, look at the diff of the generated file: it summarizes, in type form, exactly how a schema or query change affects the data the frontend receives.

For components that receive only part of a query result, derive their prop types from the generated result type with indexed access, such as PRODUCT_CARD_QUERYResult[number], rather than writing separate interfaces, so a query change flows automatically to every component that uses its data.

Configuration Reference

Setting Recommendation Why
Query definition defineQuery for every query Discovered and typed automatically.
Schema source extracted from the Studio in CI Types match the deployed schema.
Output committed generated file Reviewable diffs.
Client overloads enabled Results typed without generics.
CI regenerate, diff, typecheck Drift fails the build.
Nulls handle generated null unions Missing references are real.

Gotchas & Edge Cases

  • Dynamic queries. Queries built by string concatenation at runtime cannot be analysed. Keep queries static and use parameters for variable parts.
  • Unsupported GROQ features. A few advanced functions may be typed as unknown. Narrow those results with a runtime schema.
  • Two repositories. When Studio and frontend live in separate repositories, publish the extracted schema as an artifact or package and pin it in the frontend.
  • Nullable everything. Generated types reflect that fields can be empty. Handle nulls in components or validate required fields at the data boundary.

Worked Example

The retailer adopted TypeGen across its 60 queries in two days, mostly by wrapping queries in defineQuery and deleting hand-written interfaces. The first generation produced 140 type errors, of which about a third were real bugs: fields that no longer existed, unhandled null references and one price formatting bug similar to the incident. Since then, every schema change has shown its impact on queries in the pull request, and the team has not had a production incident caused by a schema and query mismatch. Reviews also became faster, because reviewers read the generated type diff instead of tracing each query by hand to understand what a schema change would break.

Issues found when adopting TypeGenType errors found on first generation, split into real bugs, missing null handling and harmless typing differences.Real bugs47 type errorsMissing null handling58 type errorsHarmless differences35 type errors
A third of the errors were real bugs hiding behind hand-written interfaces.

Beyond Types: Keeping Queries Healthy

Types tell you that queries and schema agree, not that queries are efficient or that content is valid. Combine TypeGen with two other practices. Review queries for performance, as described in using GROQ for complex queries, since a correctly typed query can still scan far more documents than it needs. And validate required values at runtime for pages that cannot render without them, because the schema’s required rules apply only to documents published after the rule existed; older documents may still lack fields that the types allow to be null but the page needs. Together, types, reviews and runtime checks cover the three ways the contract between Content Lake and frontend can break.

Rollout Checklist

  • Extract the schema from the Studio in CI.
  • Wrap every query in defineQuery and generate types in the frontend.
  • Commit generated types and fail CI on drift.
  • Enable client overloads for automatic result types.
  • Handle nullable results in components or at the data boundary.
  • Keep queries static, with parameters for variable parts.

Frequently Asked Questions

Does TypeGen work with GraphQL?

No, it targets GROQ. For Sanity’s GraphQL API, use standard GraphQL codegen against the deployed GraphQL schema.

Should generated types be committed?

Yes. Reviewable diffs show how schema changes affect queries, and builds do not depend on the Studio being present.

How long does generation take?

Seconds for typical projects, fast enough to run on every save in development with a watch script, and cheap enough to run on every pull request in CI without slowing reviews.

What about queries in the Studio itself?

Queries used by Studio plugins, structure and custom inputs can be typed the same way. Point TypeGen at the Studio’s source files too, or run a second configuration for the Studio package.

Do generated types replace runtime validation?

No. Types describe what the schema allows; runtime validation checks what actually arrives, including documents created before a field became required. Use both for critical pages such as checkout, pricing or legal content.

Can we type Portable Text?

Yes, the schema’s block and custom object types are generated; render them with typed component maps, so a new custom block type in the schema shows up as a missing component in the type checker.