Skip to content

feat(groq-files): infer types from .groq imports - #2035

Closed
stipsan wants to merge 1 commit into
mainfrom
cody/groq-file-types-ea75
Closed

stipsan wants to merge 1 commit into
mainfrom
cody/groq-file-types-ea75

Conversation

@stipsan

@stipsan stipsan commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Description

Adds an experimental, opt-in package @sanity/groq-files so a GROQ file can be imported as a typed query. The import is the query string, carrying its result and params types along with it. Nothing else is exported.

import {type GroqResult} from '@sanity/groq-files/types'
import getPosts, {type Result as Posts} from './posts.groq'

const posts = await client.fetch<GroqResult<typeof getPosts>>(getPosts)
//    ^? Array<{title: string; role: 'admin' | 'member'}>, same as Posts

The reference is Sébastien Lorber on TS 7.1 content mappers importing JSON as const: the mapper only makes the import carry precise types. The result type, including [0] as T | null, comes from @sanity/codegen's TypeGenerator. $param placeholders become a params object, including string enums and array fields from the schema. Existing CLI commands, defineQuery, and @sanity/client are unchanged.

How the result type is carried

// @sanity/groq-files/types
type GroqQuery<Query extends string, Result, Params> = Query & {
  readonly __groqParams?: Params
  readonly __groqResult?: Result
}
type GroqResult<Query> = Query extends {readonly __groqResult?: infer R} ? R : unknown
type GroqParams<Query> = Query extends {readonly __groqParams?: infer P} ? P : unknown

// generated posts.d.groq.ts
type Params = Record<string, never>
type Result = Array<{title: string; role: 'admin' | 'member'}>
declare const query: GroqQuery<'*[_type == "post"]{title, role}', Result, Params>
export default query
export type {Params, Result}

The brand is the query's literal type intersected with two optional phantom properties, the same device @sanity/sdk uses for __schemaTypeName. The value stays assignable to string and to its own literal, works in template literals and ===, and nothing is declared globally: no declare global, no SanityQueries augmentation, no declare module "@sanity/client". Two files with the same query text have identical types and cannot collide. The brand is keyed by plain strings so a client can read it back structurally without depending on this package.

There is no existing branded-query mechanism to reuse: defineQuery is an identity on the literal, and ClientReturn/SanityQueries/SanityQueriesByResource are all keyed by query text. A brand and the global registry are also mutually exclusive, since indexing SanityQueries with an intersection type yields unknown, so the registry path was dropped rather than combined.

What @sanity/client infers today: client.fetch(getPosts) accepts the value but returns any, because ClientReturn<G> only looks G up in SanityQueries. Pass GroqResult<typeof getPosts> as the type argument in the meantime. The minimal client-side change (separate concern, not in this stack) is to check the brand before the registry:

type ClientReturn<G extends string, Fallback = Any> = G extends {readonly __groqResult?: infer R}
  ? R
  : G extends keyof SanityQueries
    ? SanityQueries[G]
    : Fallback

plus G extends {readonly __groqParams?: infer P} ? P : QueryParams on params. @sanity/sdk's ResolveQueryResult would need the same brand-first check.

This is PR A of a stack of three:

  • A (this PR): core inference, the virtual module, the unplugin runtime plugin (Vite, Rollup, webpack, esbuild, rspack), and generateGroqDeclarations for TypeScript 5 and 6.
  • B: TypeScript 7.1 content mapper (JSON-RPC over stdio), with per-folder resources.
  • C: tsserver plugin that regenerates the declarations on save.

Changes since the last review

  • The run helper is gone, and so is the named query export. The runtime module is exactly export default "<query text>".
  • The default export is typed GroqQuery<"<text>", Result, Params>; Result and Params remain as type exports.
  • Removed everything that existed only for the global-overload approach: the declare global { interface SanityQueries } block, GroqClient, GroqRunOptions, and the @sanity/client peer dependency (the package no longer refers to the client at all).
  • Example and README rewritten around client.fetch<GroqResult<typeof q>>(q), with a section on how the type is carried and what the client would need.

What to review

  • packages/@sanity/groq-files/src/types.ts (the brand), virtualModule.ts (module text), params.ts (param inference), evaluateGroqModule.ts (TypeGenerator glue), plugin.ts + per-bundler entries, declarations.ts.
  • Workspace wiring only: pnpm-workspace.yaml, Vitest project, knip entry, lockfile importer for the new package.

Testing

33 unit tests. declarations.test.ts copies example/ to a temp dir, generates *.d.groq.ts, and runs the real tsc over a check.ts of type assertions: the default export is assignable to string and its literal, a different literal is rejected, GroqResult/GroqParams and the module's Result/Params agree (enum, string[], T | null, Record<string, never>), two files with the same text have equal types, keyof typeof module is exactly 'default', SanityQueries has nothing registered, and today's client.fetch(q) is any while client.fetch<GroqResult<typeof q>>(q) is the inferred type. plugin.test.ts bundles with Vite and esbuild and checks the bundled module has only default.

Build, build:types, typecheck, lint (--max-warnings 0), format, publint, and knip pass.

Notes for release

N/A

Open in Web Open in Cursor 

@changeset-bot

changeset-bot Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48508c7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sanity/groq-files Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@stipsan stipsan added the ✨ ai label Oct 22, 2026 — with Cursor
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @sanity/cli

Compared against main (5ffc312c)

@sanity/cli

Metric Value vs main (5ffc312)
Internal (raw) 2.4 KB -
Internal (gzip) 926 B -
Bundled (raw) 11.16 MB -
Bundled (gzip) 2.10 MB -
Import time 429ms +13ms, +3.2%

bin:sanity

Metric Value vs main (5ffc312)
Internal (raw) 782 B -
Internal (gzip) 423 B -
Bundled (raw) 9.91 MB -
Bundled (gzip) 1.78 MB -
Import time 1.64s +63ms, +4.0%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-core

Compared against main (5ffc312c)

Metric Value vs main (5ffc312)
Internal (raw) 127.8 KB -
Internal (gzip) 33.1 KB -
Bundled (raw) 22.96 MB -
Bundled (gzip) 3.71 MB -
Import time 337ms +15ms, +4.8%

🗺️ @sanity/cli-core:. treemap too large to embed · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-build

Compared against main (5ffc312c)

@sanity/cli-build/_internal/build

Metric Value vs main (5ffc312)
Internal (raw) 118.7 KB -
Internal (gzip) 30.0 KB -
Bundled (raw) 18.11 MB -
Bundled (gzip) 3.66 MB -
Import time 717ms +28ms, +4.0%

@sanity/cli-build/_internal/env

Metric Value vs main (5ffc312)
Internal (raw) 1.8 KB -
Internal (gzip) 644 B -
Bundled (raw) 1.30 MB -
Bundled (gzip) 331.9 KB -
Import time 138ms +8ms, +6.2%

@sanity/cli-build/_internal/extract

Metric Value vs main (5ffc312)
Internal (raw) 42.8 KB -
Internal (gzip) 10.3 KB -
Bundled (raw) 11.89 MB -
Bundled (gzip) 2.24 MB -
Import time 448ms +21ms, +5.0%

@sanity/cli-build/_internal/manifest

Metric Value vs main (5ffc312)
Internal (raw) 31.0 KB -
Internal (gzip) 7.8 KB -
Bundled (raw) 842.0 KB -
Bundled (gzip) 213.9 KB -
Import time 442ms +14ms, +3.3%

🗺️ ./_internal/env · ./_internal/manifest · 2 treemaps too large to embed · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — create-sanity

Compared against main (5ffc312c)

Metric Value vs main (5ffc312)
Internal (raw) 908 B -
Internal (gzip) 483 B -
Bundled (raw) 931 B -
Bundled (gzip) 491 B -
Import time ❌ ChildProcess denied: node -
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@socket-security

socket-security Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​unplugin@​3.4.0991008388100

View full report

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Array fields lose their array type
    • resolveFromType now prints the array type when the path ends on the field, and only unwraps into elements when more path segments remain.
  • ✅ Fixed: Index parameters are not inferred
    • Single-index placeholders like [$i] are collected like slice bounds, substituted before parse, and forced to number so named fetch requires a correctly typed param.
  • ✅ Fixed: Plugin session is not project-scoped
    • Each create() keeps its own session and a closed flag so projects do not share watchers and dispose before import settles skips starting one.

Create PR

Or push these changes by commenting:

@cursor push cbaf857684
Preview (cbaf857684)
diff --git a/packages/@sanity/groq-files/src/__tests__/params.test.ts b/packages/@sanity/groq-files/src/__tests__/params.test.ts
--- a/packages/@sanity/groq-files/src/__tests__/params.test.ts
+++ b/packages/@sanity/groq-files/src/__tests__/params.test.ts
@@ -29,13 +29,49 @@
     })
   })
 
-  it('types dereferences, `in` arrays, slices, and unconstrained placeholders', () => {
+  it('types dereferences, `in` arrays, slices, indexes, and unconstrained placeholders', () => {
     expect(params('*[_type == "post" && author->name == $name]')).toEqual({name: 'string'})
     expect(params('*[_type == "post" && _id in $ids]')).toEqual({ids: 'string[]'})
     expect(params('*[$start..$end]')).toEqual({end: 'number', start: 'number'})
+    expect(params('*[_type == "post"][$i]')).toEqual({i: 'number'})
+    expect(params('*[$start..$end][$i]')).toEqual({end: 'number', i: 'number', start: 'number'})
     expect(params('*[_type == "post" && $q]')).toEqual({q: 'unknown'})
   })
 
+  it('keeps array field types when comparing the whole array', () => {
+    const withTags: SchemaType = structuredClone(schema)
+    const post = withTags.find((entry) => entry.type === 'document' && entry.name === 'post')
+    if (!post || post.type !== 'document') throw new Error('expected post document')
+    post.attributes.tags = {
+      type: 'objectAttribute',
+      value: {of: {type: 'string'}, type: 'array'},
+    }
+    post.attributes.authors = {
+      type: 'objectAttribute',
+      value: {
+        of: {
+          attributes: {
+            name: {type: 'objectAttribute', value: {type: 'string'}},
+          },
+          type: 'object',
+        },
+        type: 'array',
+      },
+    }
+    const result = inferGroqParams('*[_type == "post" && tags == $tags]', withTags)
+    expect(result.ok).toBe(true)
+    if (!result.ok) return
+    expect(Object.fromEntries(result.params.map((param) => [param.name, param.type]))).toEqual({
+      tags: 'string[]',
+    })
+    const nested = inferGroqParams('*[_type == "post" && authors.name == $name]', withTags)
+    expect(nested.ok).toBe(true)
+    if (!nested.ok) return
+    expect(Object.fromEntries(nested.params.map((param) => [param.name, param.type]))).toEqual({
+      name: 'string',
+    })
+  })
+
   it('reports a syntax error with a utf-16 offset', () => {
     const result = inferGroqParams('[', schema)
     expect(result.ok).toBe(false)

diff --git a/packages/@sanity/groq-files/src/params.ts b/packages/@sanity/groq-files/src/params.ts
--- a/packages/@sanity/groq-files/src/params.ts
+++ b/packages/@sanity/groq-files/src/params.ts
@@ -40,17 +40,19 @@
 export type ParamInference = {error: unknown; ok: false} | {ok: true; params: GroqParam[]}
 
 const SLICE_PARAM = /\[\s*(?:\$(\w+)|\d+)\s*\.\.\.?\s*(?:\$(\w+)|\d+)\s*\]/g
+/** Single-index access like `[$i]` — ranges are covered by `SLICE_PARAM`. */
+const INDEX_PARAM = /\[\s*\$(\w+)\s*\]/g
 
 /**
  * Infers a params object from `$param` placeholders.
  * Comparisons against schema fields use that field's type, including string
- * enums. Slice bounds are numbers. A placeholder we cannot place is `unknown`,
- * which still makes the key required.
+ * enums. Slice bounds and single-index access are numbers. A placeholder we
+ * cannot place is `unknown`, which still makes the key required.
  */
 export function inferGroqParams(query: string, schema: SchemaType): ParamInference {
-  const sliceParams = sliceParamNames(query)
+  const indexParams = indexParamNames(query)
   const substituted: Record<string, number> = {}
-  for (const name of sliceParams) substituted[name] = 0
+  for (const name of indexParams) substituted[name] = 0
 
   let ast: GroqNode
   try {
@@ -69,7 +71,7 @@
   }
 
   walk(ast, {documents: undefined, schema}, see, add)
-  for (const name of sliceParams) add(name, 'number')
+  for (const name of indexParams) add(name, 'number')
 
   return {
     ok: true,
@@ -260,12 +262,15 @@
   return found.filter((name) => current.includes(name))
 }
 
-function sliceParamNames(query: string): string[] {
+function indexParamNames(query: string): string[] {
   const names: string[] = []
   for (const match of query.matchAll(SLICE_PARAM)) {
     if (match[1]) names.push(match[1])
     if (match[2]) names.push(match[2])
   }
+  for (const match of query.matchAll(INDEX_PARAM)) {
+    if (match[1]) names.push(match[1])
+  }
   return names
 }
 

diff --git a/packages/@sanity/groq-files/src/schemaTypes.ts b/packages/@sanity/groq-files/src/schemaTypes.ts
--- a/packages/@sanity/groq-files/src/schemaTypes.ts
+++ b/packages/@sanity/groq-files/src/schemaTypes.ts
@@ -155,10 +155,12 @@
     return parts.length === 0 ? undefined : unionTypeStrings(parts)
   }
 
+  // Keep the array type when the path ends on the field itself (`tags == $tags`).
+  // Only unwrap into elements when more segments remain (`authors.name`).
+  if (index >= segments.length) return printType(type, inlineLookup(schema), seen)
+
   if (type.type === 'array') return resolveFromType(type.of, segments, index, schema, seen)
 
-  if (index >= segments.length) return printType(type, inlineLookup(schema), seen)
-
   const segment = segments[index]
   if (!segment) return undefined
 

diff --git a/packages/@sanity/groq-files/typescript-plugin.cjs b/packages/@sanity/groq-files/typescript-plugin.cjs
--- a/packages/@sanity/groq-files/typescript-plugin.cjs
+++ b/packages/@sanity/groq-files/typescript-plugin.cjs
@@ -19,18 +19,18 @@
  * @param {unknown} _modules
  */
 function init(_modules) {
-  /** @type {{close(): void} | undefined} */
-  let session
-
   return {
     create(info) {
+      /** @type {{close(): void} | undefined} */
+      let session
+      let closed = false
       const directory = info.project.getCurrentDirectory()
       const schema =
         info.config && typeof info.config.schema === 'string' ? info.config.schema : undefined
       const logger = info.project.projectService && info.project.projectService.logger
       void load()
         .then((mod) => {
-          session?.close()
+          if (closed) return
           session = mod.startGroqTypeScriptPlugin({
             directory,
             logger: logger
@@ -51,6 +51,7 @@
       const dispose = info.languageService.dispose
       if (typeof dispose === 'function') {
         info.languageService.dispose = () => {
+          closed = true
           session?.close()
           dispose.call(info.languageService)
         }

You can send follow-ups to the cloud agent here.

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Delta

File Statements
packages/@sanity/groq-files/src/declarations.ts 100.0%
packages/@sanity/groq-files/src/evaluateGroqModule.ts 100.0%
packages/@sanity/groq-files/src/params.ts 90.8%
packages/@sanity/groq-files/src/plugin.ts 100.0%
packages/@sanity/groq-files/src/runtime.ts 100.0%
packages/@sanity/groq-files/src/schemaFile.ts 100.0%
packages/@sanity/groq-files/src/schemaTypes.ts 96.3%
packages/@sanity/groq-files/src/virtualModule.ts 98.5%

No baseline available — showing current coverage for 8 changed files

Overall Coverage

Metric Coverage
Statements 83.2%
Branches 75.9%
Functions 79.3%
Lines 83.6%

@cursor

cursor Bot commented Oct 22, 2026

Copy link
Copy Markdown

Review: @sanity/groq-files (review-only, no commits pushed)

I read every file in the package and verified the behaviour empirically rather than from the description:

  • Ran the package tests, check:types, lint, check:format, and pnpm test:coverage packages/@sanity/groq-files (all green; coverage numbers below).
  • Installed typescript@7.1.0-beta in a scratch project with this package symlinked in and ran tsc --runExternalCode (plain, --watch with a schema.json edit, syntax-error and missing-schema fixtures, enum param rejection) with TS_CONTENT_MAPPER_DEBUG=1 to inspect the JSON-RPC traffic.
  • Checked the protocol against the typescript-go #4712 write-up (initialize/openProject/transform/closeProject shapes, dynamicConfig + configIdentity + watchedFiles, utf-16 positions, stdin-EOF shutdown).
  • Ran generateGroqDeclarations and type-checked the output with the repo's TS 6.0.3 under NodeNext and Bundler.
  • Bundled a .groq import with esbuild through the plugin and type-checked the factory return types against the real esbuild/webpack plugin interfaces.
  • Probed the JSON-RPC framing (split chunks, coalesced messages, bad JSON, notifications, stdin EOF) and the $param inference with ~18 extra queries.

What works

The TS 7.1 content mapper path is in good shape: the protocol implementation matches the published spec, the host accepted it as-is, pipelined transform requests are handled correctly by the serial queue, watchedFiles/configIdentity re-open the project on a schema change, mapper diagnostics land on the right utf-16 position in the .groq file, and the process exits on stdin EOF. The inferred types match the PR's claims for the example schema (Array<{title, role: "admin" | "member"}>, [0] → T | null, $slug → {slug: string}, in lists → arrays, enum literals preserved).

Findings, ranked

Blocker

  1. The TS 5/6 fallback does not work as documented. allowArbitraryExtensions resolves ./posts.groq to posts.d.groq.ts, not posts.groq.d.ts. The committed example/tsconfig.json (NodeNext) fails with TS2307 for both imports; .groq.d.ts only resolves under bundler/node10 by extension probing, where the flag is irrelevant. Nothing in CI type-checks the example. (declarations.ts, README, example/tsconfig.json)

Should-fix
2. Virtual module exports do not match the runtime module: every schema type plus a value export (internalGroqTypeReferenceTo) is importable from every .groq file; the per-module unique symbol makes identical query text in two files produce non-identical SanityQueries entries (TS2717); and every .groq/.d.groq.ts carries the whole schema. (virtualModule.ts)
3. Under TS 7.1 the declare module "@sanity/client" bridge fails with TS2664 whenever no .ts file in the program imports @sanity/client directly (reproduced; disappears once any file imports it; fine under TS 6). (evaluateGroqModule.ts)
4. Plugin factories are typed () => {name: string}, which does not satisfy esbuild's Plugin (setup) or webpack/rspack plugin instances (apply); the five subpath exports are the same module. (plugin.ts, package.json)
5. tsserver plugin: session is shared across create() calls, so the second project closes the first project's watcher. (typescript-plugin.cjs)
6. Recursive fs.watch on the project root includes node_modules (inotify watch limits, no error handler → uncaught in tsserver) and shouldRegenerate matches any schema.json by base name. (typescriptPlugin.ts, declarations.ts)
7. Per-file API: the named export fetch shadows globalThis.fetch and there is no passthrough for client.fetch's options argument (next, perspective, stega...), which pushes Next.js users back to the untyped client.fetch. (runtime.ts, virtualModule.ts)
8. Tests: no test imports a .groq file through either real path; no run against the real host; one bundler; coverage schemaTypes.ts 58/55%, params.ts 70/56%, jsonrpc.ts 80/67%, schemaFile.ts 70%; test writes into the repo tree. (typecheck.test.ts and friends)
9. Unrelated lockfile churn: @sanity/sdk 3.8.0 → 3.9.1. (pnpm-lock.yaml)

Nits
10. JSON-RPC: non-request JSON answered as -32700/id: null instead of -32600. (jsonrpc.ts)
11. Slice-param regex matches inside string literals and invents params; != against a literal widens enums to string. (params.ts)
12. configFileName === "" falls back to process.cwd(), which is the mapper package dir under TS 7.1; isDirectRun() is a fragile start condition. (contentMapper.ts)
13. Reporter cast through unknown; unused _content param. (evaluateGroqModule.ts)
14. engines stricter than the rest of the monorepo for no reason; internal helpers exported from the root and ./declarations entries. (package.json)
15. Watcher-based test with a 4s ceiling is a flakiness risk. (typescriptPlugin.test.ts)

Scope / split

This is five independent deliverables in one PR (core inference + virtual module, unplugin loader, TS 5/6 declaration writer, tsserver plugin + watcher, TS 7.1 content mapper + JSON-RPC server) plus workspace wiring, 2.6k lines. Given the blocker sits in one of them and the mapper depends on a beta protocol, I'd split it:

  1. Package scaffold + params/schemaTypes/evaluateGroqModule/virtualModule/runtime + unplugin loader + generateGroqDeclarations (with the .d.groq.ts fix and a real tsc test) — everything the repo's TS can test today.
  2. TS 7.1 content mapper (jsonrpc.ts, contentMapper.ts, manifest field, README section) with an opt-in smoke test against typescript@beta.
  3. tsserver plugin + file watcher, once the per-project session and watch scope are sorted.

Changeset and PR template usage look right (manual changeset + N/A notes). Details are in the inline comments.

schemaNote: loaded.note,
schemaPath,
})
const output = `${file}.d.ts`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker — allowArbitraryExtensions does not look for <name>.groq.d.ts. TypeScript's convention for arbitrary extensions is <name>.d.groq.ts ({basename}.d.{ext}.ts, TS 5.0 release notes). I verified with the repo's TS 6.0.3 by running generateGroqDeclarations on example/ and type-checking the result:

  • moduleResolution: NodeNext + posts.groq.d.ts (what example/tsconfig.json does): TS2307: Cannot find module './posts.groq' or its corresponding type declarations for both imports.
  • NodeNext + posts.d.groq.ts: passes.
  • Bundler + posts.groq.d.ts: passes, but only because of node10-style extension probing (posts.groq → posts.groq.d.ts); allowArbitraryExtensions is irrelevant there.
  • Bundler + posts.d.groq.ts without the flag: TS6263: Module './posts.groq' was resolved to '.../posts.d.groq.ts', but '--allowArbitraryExtensions' is not set — so flag + .d.groq.ts is the combination the README should document.

As shipped, the TS 5/6 path only works in bundler/node10 and the committed example fails. Suggest const output = file.replace(/\.groq$/, '.d.groq.ts'), teach shouldRegenerate (line 69) to skip .d.groq.ts, fix README line 60 and example/tsconfig.json, and add a test that imports a .groq file through tsc (today's typecheck.test.ts imports ./posts.js, so it never exercises resolution). This also matches TS 7.1's own declaration-emit naming for mapped files (App.d.svelte.ts).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: declarationPath now writes <name>.d.groq.ts. README, example/tsconfig.json, and .gitignore updated. declarations.test.ts copies example/ to a temp dir, generates the declarations, and runs the real tsc (NodeNext + allowArbitraryExtensions) over main.ts plus a check.ts of type assertions. shouldRegenerate moved to PR C and only matches *.groq or the exact schema path, so .d.groq.ts never triggers.

Comment thread packages/@sanity/groq-files/README.md Outdated

## TypeScript 5 and 6

The language service cannot invent a virtual file. Two opt-in fallbacks write the same TypeScript next to the query, and `allowArbitraryExtensions` makes `import x from './file.groq'` resolve to `file.groq.d.ts`:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker (same as declarations.ts) — this is not how allowArbitraryExtensions behaves: it resolves ./file.groq to file.d.groq.ts. With the current .groq.d.ts output the flag does nothing, and the import only resolves under bundler/node10 resolution (and fails under node16/nodenext).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the README now documents posts.d.groq.ts with allowArbitraryExtensions, links the TS 5.0 release notes, and shows the NodeNext config the test runs.

"noEmit": true,
"rootDir": "."
},
"files": ["main.ts", "posts.groq.d.ts", "postBySlug.groq.d.ts"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocker (follow-up) — this project does not type-check as committed (TS2307 on both .groq imports under NodeNext, see the declarations.ts comment), and nothing checks it: the package tsconfig.json includes only ./src and eslint ignores example/**. Either wire the example into a test or check:types (generate the declarations into a temp dir, run tsc), or drop it; a non-compiling example inside the package is worse than none.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: example/ is now wired into declarations.test.ts, which generates the declarations in a temp copy and type-checks main.ts and a check.ts of assertions with the repo's tsc. The example also gained postsByRole.groq (enum param) and postsByTags.groq (array param). PR B runs the same example through TS 7.1's content mapper.

const fetchParams = options.params.length === 0 ? '' : `, params: __GroqFileParams`
const result = isTypeName(options.resultType) ? options.resultType : 'unknown'

return `${notes.length > 0 ? `${notes.join('\n')}\n` : ''}${options.generated}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — splicing the entire TypeGenerator output into every virtual module has three consequences I reproduced against typescript@7.1.0-beta:

  1. The .groq module's type-level exports do not match the runtime module. import {internalGroqTypeReferenceTo} from './posts.groq' type-checks (codegen emits export declare const internalGroqTypeReferenceTo: unique symbol), but the bundled module only exports ['default', 'fetch', 'query'], so it is undefined at runtime. Every schema type (Post, Author, AllSanitySchemaTypes, ...) is also importable from every .groq file.
  2. internalGroqTypeReferenceTo is a fresh unique symbol per virtual module, so Post from a.groq and Post from b.groq are different types. Two .groq files with identical query text whose result includes a reference object register the same SanityQueries key with non-identical types → TS2717. (GenerateTypesOptions.overloadClientMethods in codegen documents exactly this failure mode.)
  3. Every .groq file, and every generated .d.groq.ts on disk for TS 5/6, carries the whole schema. With a real schema that is thousands of lines per query file, regenerated on every schema change.

The PR lists "one shared schema module" as a follow-up, but it changes the public shape of every .groq module, so I'd rather see it before this ships, even as experimental: emit schema types once (a single shared module, supplemental output, or global declarations) and have the virtual module only export default, export {fetch, query}, plus a type-only result export. At minimum add a test asserting that the virtual module's value exports equal the runtime module's exports.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, all three. The module's value exports are now exactly default, query, and run (matching the runtime module; evaluate.test.ts asserts the export surface), plus type-only Result and Params. Schema aliases are no longer exported: only the ones the result references (transitively) are inlined as local types, so a projection carries no schema at all. internalGroqTypeReferenceTo is imported from @sanity/groq-files/types (one Symbol.for declaration) instead of being declared per file, so identical queries in two files produce identical types; the tsc test includes a duplicate-query file and compiles without TS2717.

const generator = options.generator ?? new TypeGenerator()
const variable = variableNameFromFilename(options.filename)
const {code} = await generator.generateTypes({
overloadClientMethods: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — overloadClientMethods: true makes codegen emit declare module "@sanity/client" { interface SanityQueries extends globalThis.SanityQueries {} }. With typescript@7.1.0-beta --runExternalCode, a program where no .ts file imports @sanity/client (I only imported from ./posts.groq; @sanity/client was installed in node_modules) fails with:

posts.groq(45,16): error TS2664: Invalid module name in augmentation, module '@sanity/client' cannot be found.
  This location is in virtual code produced by the content mapper '@sanity/groq-files@0.0.0' and has no corresponding location in the original file.

As soon as any file in the program imports @sanity/client, the error disappears; the same text as a .d.groq.ts under TS 6.0.3 is fine either way. Projects that reach the client indirectly (next-sanity, @sanity/sdk, sanity) will see this on every .groq file in the editor. Options: only emit the declare module bridge when @sanity/client resolves from root (the mapper has it), or drop the bridge, rely on the global SanityQueries registry that codegen already emits, and tighten the peer range to the client versions that read it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: dropped the bridge (overloadClientMethods: false) and render the global SanityQueries entry directly. The peer range ^7.27.0 || ^8.5.0 already matches the client versions that read the global (sanity-io/client#1319, #1320). PR B's TS 7.1 smoke test runs --runExternalCode on a program where @sanity/client is only type-imported and passes with no TS2664.

name: '@sanity/groq-files',
}))

export const groqFilesPlugin = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — typing every factory as () => {name: string} breaks consumers at the type level for three of the five bundlers. Checked with TS 6.0.3 against the real plugin interfaces:

types.ts(5,7): error TS2741: Property 'setup' is missing in type 'GroqBundlerPlugin' but required in type 'Plugin'.                 // esbuild
types.ts(9,7): error TS2741: Property 'apply' is missing in type 'GroqBundlerPlugin' but required in type 'WebpackPluginInstance'.  // webpack; rspack has the same shape

Vite and Rollup only require name, so they happen to work. esbuild/webpack/rspack users will need as any. unplugin ships per-bundler types (UnpluginInstance<...>['esbuild'] etc.); if api-extractor cannot follow the optional rspack types, declare minimal structural return types locally ({name: string; setup(build: ...): void}, {name: string; apply(compiler: ...): void}) rather than erasing them.

Related: ./vite, ./rollup, ./webpack, ./esbuild and ./rspack in package.json all resolve to this same module, so import {webpackPlugin} from '@sanity/groq-files/vite' works. Either make each subpath export only its bundler's plugin (the unplugin convention) or keep a single entry; five identical subpaths are confusing to document.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: createUnplugin<undefined, false> plus minimal structural return types per bundler ({name; setup(build)} for esbuild, {apply(compiler)} for webpack/rspack, {name} for Vite/Rollup). plugin.test.ts asserts assignability to Vite's and esbuild's real Plugin types. Each subpath (/vite, /rollup, /webpack, /esbuild, /rspack) is its own entry exporting only that bundler's factory as the default export; the root exports the five as groqFilesPlugin.


run()
try {
watcher = watch(directory, {recursive: true}, (_event, filename) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — this watches the whole project directory recursively, including node_modules. On Linux, Node implements recursive: true with one inotify watch per directory: on this checkout that is ~13.5k directories and ~0.9s just to set up, and on a large npm/yarn tree it can exceed fs.inotify.max_user_watches (8192/65536 on common defaults). When that happens the watcher emits 'error', and there is no error listener here, so it becomes an uncaught exception inside tsserver. Related: shouldRegenerate (declarations.ts line 70) only compares the base name, so any schema.json anywhere under the root, including node_modules/**/schema.json, triggers a full regeneration of every .groq file.

Suggest: attach an error handler, exclude SKIP_DIRECTORIES from the watch (watch the directories that actually contain .groq files plus the schema file, instead of the root), and compare the full path to the resolved schema path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR C (#2038): no recursive watch. The plugin watches, non-recursively, the directories that contain .groq files (from the generator's output), the project root, and the schema's directory, so node_modules is never touched. Every watcher has an error handler that logs to tsserver, and regeneration triggers only for *.groq or the exact resolved schema path.

*/
function init(_modules) {
/** @type {{close(): void} | undefined} */
let session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — session lives in init()'s scope, but tsserver calls create() once per project. With two tsconfigs (tsconfig.json + tsconfig.node.json, or any monorepo) the second create() runs session?.close() and tears down the first project's watcher, and the patched dispose of either project closes whichever session is current. Move session inside create() (one per project), and consider returning a proxy of the language service rather than mutating info.languageService.dispose.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR C (#2038): the session lives inside create(), one per project, and create() returns a proxy of the language service whose dispose closes that session, instead of mutating info.languageService.dispose. Disposing before the ESM import settles sets a closed flag so no session starts. Covered by a test that requires the built .cjs.

* Module source a bundler evaluates for a `.groq` import.
* The default export is the query string. `fetch(client, params)` runs it.
*/
export function createRuntimeModule(content: string): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix (API design) — two things about the per-file API, which is the real public contract of this package and will be hard to change later:

  1. The named export is fetch. import {fetch} from './posts.groq' silently shadows globalThis.fetch in the importing module; the README has to alias it everywhere (fetch as fetchPosts), which is a hint that the name fights the platform. run/execute/fetchQuery would avoid the footgun.
  2. There is no way to pass client.fetch's third argument (perspective, useCdn, filterResponse, stega, next: {revalidate}, ...). Next.js users in particular rely on client.fetch(query, params, {next}); today they have to drop back to client.fetch(getPosts, params) and lose the strict params. A passthrough fetch(client, params, options?) here and in virtualModule.ts (typed off the client, e.g. Parameters<C['fetch']>[2]) keeps the typed path usable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed both: the named export is run, and run(client, params, options?) forwards the third argument to client.fetch. options is typed from the passed client (GroqRunOptions<Client> in @sanity/groq-files/types reads the last parameter of Client['fetch'] and omits filterResponse/returnQuery, since run always returns the filtered result). The tsc test passes perspective/useCdn/tag and asserts filterResponse is rejected.

Comment thread pnpm-lock.yaml Outdated
'@sanity/sdk':
specifier: ^3.8.0
version: 3.8.0
version: 3.9.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — unrelated lockfile churn: @sanity/sdk moves from 3.8.0 to 3.9.1 (and the 3.8.0 snapshot is dropped) because the lockfile was regenerated without --frozen-lockfile. Please keep this PR's lockfile diff to the new packages/@sanity/groq-files importer, unplugin, and webpack-virtual-modules; bump the SDK separately if wanted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the lockfile is main's plus the new packages/@sanity/groq-files importer and its dependencies (unplugin, webpack-virtual-modules, @sanity/worker-channels, esbuild). @sanity/sdk stays at 3.8.0; pnpm install --frozen-lockfile passes.

if (buffer.length < bodyEnd) return undefined
try {
const request = JSON.parse(buffer.toString('utf8', bodyStart, bodyEnd)) as unknown
if (!isRequest(request)) return {consumed: bodyEnd, item: {kind: 'parse'}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — valid JSON that is not a request (and a bad Content-Length) is answered with -32700 Parse error and id: null; JSON-RPC 2.0 reserves -32700 for unparseable JSON and uses -32600 Invalid Request (echoing the id when present) for this case. For the record, the framing held up in my probes: split mid-header/mid-body, two messages in one chunk, notifications without id, and the process exits cleanly on stdin EOF, which is the shutdown signal the TS host relies on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in PR B (#2037): unparseable JSON is -32700; valid JSON that is not a request, and a bad Content-Length, is -32600 Invalid Request echoing the id when present (null otherwise). Tests cover split/packed frames, notifications, both error kinds, end while a request is in flight, and the error event. Thanks for probing the framing.

function checkSource(): string {
return `import {type SanityClient} from '@sanity/client'

import getBySlug, {fetch as fetchBySlug} from './postBySlug.js'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix (tests) — this is the only type-level test and it imports ./posts.js/./postBySlug.js, i.e. the rendered text as ordinary modules. It never exercises import ... from './x.groq' through either real path (allowArbitraryExtensions + declaration file, or the content mapper), which is how the .groq.d.ts naming bug got through. Suggestions:

  • Run tsc on a fixture that imports ./posts.groq with the generated declaration (TS 6 is already a devDependency).
  • Add an opt-in/CI smoke test against typescript@7.1.0-beta (tsc --runExternalCode -p example). I did this manually and the round-trip works, including pipelined transforms and a schema.json edit under --watch (new configIdentity, re-transform, types update), so it is worth locking in.
  • Cover the enum param (role: "admin" | "member" rejecting 'nope'), string[] params, unknown params, and fetch(client) rejecting a second argument on a no-param query.
  • expectTypeOf<SanityClient['fetch']>().toBeFunction() on line 57 asserts nothing about this package.
  • Coverage from pnpm test:coverage packages/@sanity/groq-files: schemaTypes.ts 58% stmts / 55% branches, params.ts 70% / 56%, jsonrpc.ts 80% / 67% (parse error, bad Content-Length, error event, end while a request is in flight are untested), schemaFile.ts 70% (invalid JSON / non-array branches). CONTRIBUTING asks for maximum coverage on new code.
  • Only Vite (rolldown under the hood) is exercised in plugin.test.ts; esbuild is already in the lockfile and a ten-line test covers the onLoad path (I ran one manually; it works).
  • Line 23 writes into packages/@sanity/groq-files/.tmp inside the repo; tmpdir() like the other tests avoids polluting the checkout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, point by point: declarations.test.ts runs the real tsc over the example importing ./x.groq through generated .d.groq.ts, with assertions for the enum param rejecting 'nope', string[] params, a no-param query rejecting params, filterResponse rejected, and client.fetch(getPosts) equal to run's result. The TS 7.1 smoke test is in PR B (#2037), opt-in via GROQ_FILES_TSC71, and passed against 7.1.0-beta. The toBeFunction() assertion is gone. plugin.test.ts bundles with esbuild as well as Vite and imports the output. Everything writes to tmpdir(). New focused tests for schemaTypes.ts, params.ts, schemaFile.ts, and virtualModule.ts; package coverage is ~95% statements.


export type ParamInference = {error: unknown; ok: false} | {ok: true; params: GroqParam[]}

const SLICE_PARAM = /\[\s*(?:\$(\w+)|\d+)\s*\.\.\.?\s*(?:\$(\w+)|\d+)\s*\]/g

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the slice regex runs over raw query text, so it also fires inside string literals: *[_type == "post" && title == "[0..$x]"] infers a required x: number that the query never reads (and substitutes 0 for it before parsing). Low likelihood, but a param that does not exist is a confusing error for the user; consider deriving slice parameters from the AST after a first parse attempt, or at least skipping matches inside string literals. Also $role != "admin" widens the schema enum to string (widened, line 179) where keeping the literal would preserve the union; your call.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the first: the slice regex runs over a copy of the query with string-literal contents blanked out, so title == "[0..$x]" no longer invents $x (test added). Kept != widening to string on purpose: $role != "admin" is commonly used to pass a value outside the enum, and narrowing it would reject valid calls. Happy to flip it if you feel strongly.

end() {},
},
},
} as unknown as NonNullable<GenerateTypesOptions['reporter']>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the hand-rolled reporter cast through unknown will silently break when TypegenWorkerChannel changes. @sanity/worker-channels (already a transitive dependency via codegen) exposes new WorkerChannelReporter<TypegenWorkerChannel>(message => ...), which gives a typed reporter from a postMessage callback; the evaluatedModules stream can be collected from those messages without the cast. Also _content on line 213 is unused.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the reporter is new WorkerChannelReporter<TypegenWorkerChannel>(message => ...) from @sanity/worker-channels, collecting the generatedSchemaTypes event and the evaluatedModules emission from typed messages. The unused _content parameter is gone.

}
}

const root = configFileName.length > 0 ? dirname(configFileName) : process.cwd()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — per the protocol configFileName is "" for a project without a config file. TS 7.1 spawns the mapper with cwd set to the package's real path (probed: cwd=/.../node_modules/@sanity/groq-files), so this fallback resolves schema.json inside the mapper package and the diagnostic points users at a path under node_modules. Nobody hits it until an editor extension registers an inferred-project contribution, but it is cheap to handle explicitly (require an absolute options.schema, or resolve relative to the transformed file's directory at transform time).

Separately, isDirectRun() (line 211) is a fragile start condition; it happens to work because TS passes the absolute real path as argv[1], but a dedicated always-start entry (dist/bin/contentMapper.js) that imports the handler module removes the dependency on how the host spawns it, and sidesteps symlink/drive-letter-casing mismatches.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in PR B (#2037): with configFileName === "" the mapper requires an absolute schema option; otherwise it reports an option diagnostic (code 4) and transforms untyped with a schema diagnostic, never touching process.cwd(). root for such projects is the transformed file's directory. isDirectRun() is replaced by a dedicated entry, dist/bin/contentMapper.js, referenced from typescript.contentMapper.exec.

]
}
},
"engines": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — engines is ^22.18.0 || >=24.11.0 while every other package in the repo is >=22.12; nothing here needs the stricter range (Promise.withResolvers landed in Node 22.0), so this only rejects otherwise-fine Node 22.12–22.17 installs. Also the root entry exports createRuntimeModule/isGroqRequest and ./declarations exports shouldRegenerate/schemaBaseName, which read as internals; keeping the public surface to the plugin factories and generateGroqDeclarations makes an eventual 1.0 easier.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: engines is >=22.12 like the rest of the repo. The root entry exports only groqFilesPlugin and the four plugin types; ./declarations exports only generateGroqDeclarations (and declarationPath); createRuntimeModule, isGroqRequest, shouldRegenerate, and schemaBaseName are internal now.

const SCHEMA = new URL('../../example/schema.json', import.meta.url)

describe('typescript plugin', () => {
it('writes declarations on startup and when a .groq file is saved', async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — a real recursive watcher with a 4s ceiling is a flakiness risk on CI runners (recursive-watch setup on Linux scales with directory count). Consider injecting the watcher, or unit-testing shouldRegenerate + the debounce/regenerate path without touching the filesystem watcher.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR C (#2038): the watcher is injectable, so debounce, filtering, rewatching, run coalescing, and error reporting are tested under fake timers with no filesystem watcher. One remaining real-fs test uses the new non-recursive watcher on a temp directory with two subdirectories, which is a handful of inotify watches rather than a recursive walk.

@cursor
cursor Bot force-pushed the cody/groq-file-types-ea75 branch from 0d9c5bb to 48508c7 Compare October 22, 2026 10:44

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: in unwraps union array types
    • Added elementOf to unwrap each top-level union arm before stripping []/Array<>, so $x in across multi-doc array fields yields a valid element union.

Create PR

Or push these changes by commenting:

@cursor push 72abcb62c9
Preview (72abcb62c9)
diff --git a/packages/@sanity/groq-files/src/__tests__/params.test.ts b/packages/@sanity/groq-files/src/__tests__/params.test.ts
--- a/packages/@sanity/groq-files/src/__tests__/params.test.ts
+++ b/packages/@sanity/groq-files/src/__tests__/params.test.ts
@@ -47,6 +47,57 @@
     expect(params('*[_type == "post" && title match $term]')).toEqual({term: 'string'})
   })
 
+  it('unwraps each arm when `in` sees a union of array fields across documents', () => {
+    const multi: SchemaType = [
+      ...schema,
+      {
+        attributes: {
+          _type: {type: 'objectAttribute', value: {type: 'string', value: 'page'}},
+          items: {
+            type: 'objectAttribute',
+            value: {
+              of: {
+                attributes: {a: {type: 'objectAttribute', value: {type: 'number'}}},
+                type: 'object',
+              },
+              type: 'array',
+            },
+          },
+          tags: {
+            type: 'objectAttribute',
+            value: {of: {type: 'number'}, type: 'array'},
+          },
+        },
+        name: 'page',
+        type: 'document',
+      },
+      {
+        attributes: {
+          _type: {type: 'objectAttribute', value: {type: 'string', value: 'note'}},
+          items: {
+            type: 'objectAttribute',
+            value: {
+              of: {
+                attributes: {b: {type: 'objectAttribute', value: {type: 'string'}}},
+                type: 'object',
+              },
+              type: 'array',
+            },
+          },
+        },
+        name: 'note',
+        type: 'document',
+      },
+    ]
+    const result = (query: string) => {
+      const inferred = inferGroqParams(query, multi)
+      if (!inferred.ok) throw new Error(syntaxDiagnostic(inferred.error).message)
+      return Object.fromEntries(inferred.params.map((param) => [param.name, param.type]))
+    }
+    expect(result('*[ $tag in tags]')).toEqual({tag: 'number | string'})
+    expect(result('*[ $x in items]')).toEqual({x: '{a: number} | {b: string}'})
+  })
+
   it('types dereferences, `in` arrays, slices, and unconstrained placeholders', () => {
     expect(params('*[_type == "post" && author->name == $name]')).toEqual({name: 'string'})
     expect(params('*[_type == "post" && _id in $ids]')).toEqual({ids: 'string[]'})

diff --git a/packages/@sanity/groq-files/src/__tests__/schemaTypes.test.ts b/packages/@sanity/groq-files/src/__tests__/schemaTypes.test.ts
--- a/packages/@sanity/groq-files/src/__tests__/schemaTypes.test.ts
+++ b/packages/@sanity/groq-files/src/__tests__/schemaTypes.test.ts
@@ -1,7 +1,7 @@
 import {type SchemaType} from 'groq-js'
 import {describe, expect, it} from 'vitest'
 
-import {arrayOf, lookupPathType, unionTypeStrings} from '../schemaTypes.js'
+import {arrayOf, elementOf, lookupPathType, unionTypeStrings} from '../schemaTypes.js'
 
 const schema: SchemaType = [
   {
@@ -121,4 +121,12 @@
     expect(arrayOf('"a" | "b"')).toBe('Array<"a" | "b">')
     expect(arrayOf('{x: number}')).toBe('Array<{x: number}>')
   })
+
+  it('unwraps each arm of a union of array types', () => {
+    expect(elementOf('string[]')).toBe('string')
+    expect(elementOf('Array<"a" | "b">')).toBe('"a" | "b"')
+    expect(elementOf('number[] | string[]')).toBe('number | string')
+    expect(elementOf('Array<{a: number}> | Array<{b: string}>')).toBe('{a: number} | {b: string}')
+    expect(elementOf('string[] | number')).toBe('number | string')
+  })
 })

diff --git a/packages/@sanity/groq-files/src/params.ts b/packages/@sanity/groq-files/src/params.ts
--- a/packages/@sanity/groq-files/src/params.ts
+++ b/packages/@sanity/groq-files/src/params.ts
@@ -1,6 +1,12 @@
 import {GroqSyntaxError, parse, type SchemaType} from 'groq-js'
 
-import {arrayOf, lookupPathType, type PathSegment, unionTypeStrings} from './schemaTypes.js'
+import {
+  arrayOf,
+  elementOf,
+  lookupPathType,
+  type PathSegment,
+  unionTypeStrings,
+} from './schemaTypes.js'
 
 /**
  * groq-js 2.0 tags every AST node with `type`, but that union is not exported
@@ -174,10 +180,7 @@
   }
 
   const inner = valueType(node, context)
-  if (inner === undefined) return undefined
-  if (inner.endsWith('[]')) return inner.slice(0, -2)
-  if (inner.startsWith('Array<') && inner.endsWith('>')) return inner.slice('Array<'.length, -1)
-  return inner
+  return inner === undefined ? undefined : elementOf(inner)
 }
 
 function widened(value: unknown): string | undefined {

diff --git a/packages/@sanity/groq-files/src/schemaTypes.ts b/packages/@sanity/groq-files/src/schemaTypes.ts
--- a/packages/@sanity/groq-files/src/schemaTypes.ts
+++ b/packages/@sanity/groq-files/src/schemaTypes.ts
@@ -56,6 +56,21 @@
   return element.includes(' ') || element.includes('|') ? `Array<${element}>` : `${element}[]`
 }
 
+/**
+ * Inverse of {@link arrayOf}: strip one array layer from each top-level union arm.
+ * Needed when {@link lookupPathType} joins fields across documents into
+ * `number[] | string[]` or `Array<{a: number}> | Array<{b: string}>`.
+ */
+export function elementOf(arrayType: string): string {
+  return unionTypeStrings(
+    splitTopLevelUnion(arrayType).map((part) => {
+      if (part.endsWith('[]')) return part.slice(0, -2)
+      if (part.startsWith('Array<') && part.endsWith('>')) return part.slice('Array<'.length, -1)
+      return part
+    }),
+  )
+}
+
 function printType(type: TypeNode, lookupInline: InlineLookup, seen: Set<string>): string {
   switch (type.type) {
     case 'array': {

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 48508c7. Configure here.

const inner = valueType(node, context)
if (inner === undefined) return undefined
if (inner.endsWith('[]')) return inner.slice(0, -2)
if (inner.startsWith('Array<') && inner.endsWith('>')) return inner.slice('Array<'.length, -1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in unwraps union array types

Medium Severity

elementType strips one [] or wrapping Array<...> from the whole printed type string. When valueType returns a union of array types, that check treats the union as a single array and emits a wrong or invalid param type, so $x in field across multiple document types can fail typecheck or accept the wrong value.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 48508c7. Configure here.

@stipsan

stipsan commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Superseded by sanity-io/codegen#176 (typed .groq imports and the @sanity/codegen/content-mapper) and sanity-io/codegen#177 (runtime loaders for Vite, esbuild, webpack and Turbopack). This work now lives in @sanity/codegen.

@stipsan stipsan closed this Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants