Visitar URL original
feat: export SchemaTypeGenerator by stipsan · Pull Request #176 · sanity-io/codegen · GitHub
Skip to content

feat: export SchemaTypeGenerator - #176

Merged
stipsan merged 1 commit into
mainfrom
cody/groq-file-types-3a53
Oct 22, 2026
Merged

stipsan merged 1 commit into
mainfrom
cody/groq-file-types-3a53

Conversation

@stipsan

@stipsan stipsan commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

The codegen side of sanity-labs/groq-content-mapper#1, the TypeScript 7.1 content mapper that types .groq file imports, in the way sanity-io/client#1373 is the client side. The feature itself lives in that package, which depends on this one; this PR exports the one class it needs.

import {SchemaTypeGenerator} from '@sanity/codegen'

const schemaTypes = new SchemaTypeGenerator(schema)
schemaTypes.generateTsType(typeNode) // a groq-js type node as a TypeScript type
for (const {name, id, tsType} of schemaTypes) {} // the schema's aliases, in schema order

SchemaTypeGenerator is the class TypeGenerator already builds internally to turn a schema and groq-js type nodes into TypeScript types. It is now exported, marked @beta, and generateTsType is public (it was the only private method the mapper needs; everything else it calls, evaluateQuery and the iterator, was public already). The mapper constructs one per schema, calls generateTsType on a query's result and on the types inferred for its $ params, and iterates it for the schema aliases a result refers to, so what it types for a .groq import is what sanity typegen generate writes for the same query. Until this ships, the mapper reaches the same instance through TypeGenerator's private selector, which this export replaces.

Nothing else in the public API changes. generateTsType moved up among the public members, which is what the long diff in schemaTypeGenerator.ts is; the body is unchanged.

Verification

  • New tests on the public surface: the class is exported from the package entry; generateTsType types a result node that is not in the schema, naming schema types by alias (AuthorReference, Slug | null), a string literal, and a missing inline type as unknown with the existing note; ArrayOf use is reported through isArrayOfUsed; iteration yields {name, id, tsType} in schema order, with internalGroqTypeReferenceTo on a reference type.
  • Lint, typecheck, pnpm test (16 files, 195 tests), build and size-limit pass; api-extractor accepts the new export with its release tag, and the bundle is unchanged within noise (raw 5 480 271 → 5 480 299 bytes).

A separate one-line fix that came out of the same work, slice bounds such as [10...$end] failing typegen, is #179.

Open in Web Open in Cursor 

@stipsan stipsan added the ✨ ai label Oct 22, 2026 — with Cursor
@pkg-pr-new

pkg-pr-new Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
npm i https://pkg.pr.new/@sanity/codegen@176

commit: dc45b45

@socket-security

socket-security Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

size-limit report 📦

Path Size
@sanity/codegen bundle (raw) 5.23 MB (+0.01% 🔺)
@sanity/codegen bundle (Brotli) 1.01 MB (-0.04% 🔽)

@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.

Autofix Details

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

  • ✅ Fixed: Wrong schema wins for nested paths
    • specificity is now the length of the longest matching path for each file, so nested overrides beat grouped parent resources with longer sibling paths.

Create PR

Or push these changes by commenting:

@cursor push e8a99bf9d8
Preview (e8a99bf9d8)
diff --git a/src/groqFiles/__tests__/resources.test.ts b/src/groqFiles/__tests__/resources.test.ts
--- a/src/groqFiles/__tests__/resources.test.ts
+++ b/src/groqFiles/__tests__/resources.test.ts
@@ -142,6 +142,28 @@
     ])
   })
 
+  test('scores specificity by the matching path, not a longer sibling on the same resource', async () => {
+    const dir = await project({
+      'schema.json': await readFile(SCHEMA, 'utf8'),
+      'shop/schema.json': SHOP_SCHEMA,
+    })
+    const resolved = await resolveGroqFiles({
+      options: {
+        resources: [
+          {path: ['src', 'packages/very-long-name'], schema: './schema.json'},
+          {path: 'src/blog', schema: 'shop/schema.json'},
+        ],
+        schema: './schema.json',
+      },
+      root: dir,
+    })
+    const schemaPathFor = (file: string) => schemaFor(resolved, join(dir, file)).schemaPath
+
+    expect(schemaPathFor('src/blog/posts.groq')).toBe(join(dir, 'shop', 'schema.json'))
+    expect(schemaPathFor('src/other/page.groq')).toBe(join(dir, 'schema.json'))
+    expect(schemaPathFor('packages/very-long-name/x.groq')).toBe(join(dir, 'schema.json'))
+  })
+
   test('requires absolute paths when there is no root to resolve against', async () => {
     const dir = await project({'schema.json': await readFile(SCHEMA, 'utf8')})
     const none = await resolveGroqFiles({options: {}})

diff --git a/src/groqFiles/resources.ts b/src/groqFiles/resources.ts
--- a/src/groqFiles/resources.ts
+++ b/src/groqFiles/resources.ts
@@ -123,8 +123,11 @@
 export interface ResolvedGroqFileResource extends GroqFileSchema {
   /** Whether this resource types `file`, an absolute path. */
   claims: (file: string) => boolean
-  /** How specific the resource is: the longest matching `path` wins. */
-  specificity: number
+  /**
+   * How specific this resource is for `file`: the length of its longest `path` that claims the
+   * file. Used so a nested folder can override a parent when both resources match.
+   */
+  specificity: (file: string) => number
 }
 
 /** `GroqFilesOptions` with every path resolved and every schema read. */
@@ -194,12 +197,20 @@
     const paths = Array.isArray(resource.path) ? resource.path : [resource.path]
     const matchers = paths.flatMap((path) => {
       const located = locate(path)
-      return located === undefined ? [] : [matcher(root ?? parse(located).root, located)]
+      return located === undefined
+        ? []
+        : [{matches: matcher(root ?? parse(located).root, located), specificity: path.length}]
     })
     resources.push({
       ...schema,
-      claims: (file) => matchers.some((matches) => matches(file)),
-      specificity: Math.max(...paths.map((path) => path.length)),
+      claims: (file) => matchers.some(({matches}) => matches(file)),
+      specificity: (file) => {
+        let best = -1
+        for (const {matches, specificity} of matchers) {
+          if (matches(file) && specificity > best) best = specificity
+        }
+        return best
+      },
     })
   }
 
@@ -214,9 +225,10 @@
   let best: GroqFileSchema = resolved.fallback
   let specificity = -1
   for (const resource of resolved.resources) {
-    if (resource.specificity > specificity && resource.claims(file)) {
+    const resourceSpecificity = resource.specificity(file)
+    if (resourceSpecificity > specificity) {
       best = resource
-      specificity = resource.specificity
+      specificity = resourceSpecificity
     }
   }
   return best

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

Comment thread src/groqFiles/resources.ts Resolved
@cursor
cursor Bot force-pushed the cody/groq-file-types-3a53 branch 2 times, most recently from 2b9754f to 7b5e380 Compare October 22, 2026 19:20

@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.

Autofix Details

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

  • ✅ Fixed: Schema aliases shadow builtin types
    • Skip Array/Record when they appear as parameterized lib generics, and rename colliding schema aliases so they no longer shadow the builtins.

Create PR

Or push these changes by commenting:

@cursor push 20238c0af7
Preview (20238c0af7)
diff --git a/src/groqFiles/__tests__/groqModule.test.ts b/src/groqFiles/__tests__/groqModule.test.ts
--- a/src/groqFiles/__tests__/groqModule.test.ts
+++ b/src/groqFiles/__tests__/groqModule.test.ts
@@ -208,3 +208,62 @@
     expect(code).not.toContain('type QueryResponse_2')
   })
 })
+
+describe('TypeGenerator.generateGroqModule with a schema type named Array or Record', () => {
+  const schema = [
+    {
+      attributes: {
+        _id: {type: 'objectAttribute', value: {type: 'string'}},
+        _type: {type: 'objectAttribute', value: {type: 'string', value: 'record'}},
+        title: {type: 'objectAttribute', value: {type: 'string'}},
+      },
+      name: 'record',
+      type: 'document' as const,
+    },
+    {
+      attributes: {
+        _id: {type: 'objectAttribute', value: {type: 'string'}},
+        _type: {type: 'objectAttribute', value: {type: 'string', value: 'array'}},
+        title: {type: 'objectAttribute', value: {type: 'string'}},
+      },
+      name: 'array',
+      type: 'document' as const,
+    },
+    {
+      attributes: {
+        _id: {type: 'objectAttribute', value: {type: 'string'}},
+        _type: {type: 'objectAttribute', value: {type: 'string', value: 'post'}},
+        arr: {type: 'objectAttribute', value: {name: 'array', type: 'inline'}},
+        title: {type: 'objectAttribute', value: {type: 'string'}},
+      },
+      name: 'post',
+      type: 'document' as const,
+    },
+  ]
+
+  test('does not emit unused Array/Record aliases that would shadow the builtins', () => {
+    const {code} = new TypeGenerator().generateGroqModule({
+      content: '*[_type == "post"]{title}',
+      filename: 'posts.groq',
+      schema,
+    })
+    expect(code).toContain('export type QueryResponse = Array<{')
+    expect(code).toContain('export type QueryParams = Record<string, never>;')
+    expect(code).not.toMatch(/^type Array\b/m)
+    expect(code).not.toMatch(/^type Record\b/m)
+  })
+
+  test('renames a referenced Array alias so the Array builtin stays generic', () => {
+    const {code} = new TypeGenerator().generateGroqModule({
+      content: '*[_type == "post"]{arr}',
+      filename: 'posts.groq',
+      schema,
+    })
+    expect(code).toContain('type Array_2 = {')
+    expect(code).toContain('arr: Array_2;')
+    expect(code).toContain('export type QueryResponse = Array<{')
+    expect(code).not.toMatch(/^type Array =/m)
+    expect(code).toContain('export type QueryParams = Record<string, never>;')
+    expect(code).not.toMatch(/^type Record\b/m)
+  })
+})

diff --git a/src/groqFiles/groqModule.ts b/src/groqFiles/groqModule.ts
--- a/src/groqFiles/groqModule.ts
+++ b/src/groqFiles/groqModule.ts
@@ -17,8 +17,14 @@
 export const GROQ_RESULT_BRAND = '__groqResult'
 export const GROQ_PARAMS_BRAND = '__groqParams'
 
+/**
+ * Lib generics the module writes itself (`Array<…>`, `Record<…>`). A schema alias with the same
+ * name would shadow them and break every list result and empty-params type.
+ */
+const BUILTIN_GENERICS = new Set(['Array', 'Record'])
+
 /** Names a module declares itself, which a schema type alias cannot take. */
-const MODULE_NAMES = new Set(['query', QUERY_PARAMS, QUERY_RESPONSE])
+const MODULE_NAMES = new Set(['query', QUERY_PARAMS, QUERY_RESPONSE, ...BUILTIN_GENERICS])
 
 /**
  * Why a `.groq` file could not be typed, or not entirely. Reported by the content mapper with
@@ -288,6 +294,17 @@
 }
 
 function visitIdentifiers(node: t.Node, visit: (identifier: t.Identifier) => void): void {
+  // `Array<T>` / `Record<K, V>` name the lib generics, not schema aliases — skip the type name so
+  // collectReferences does not pull those aliases in, and renamed does not rewrite the builtins.
+  if (
+    t.isTSTypeReference(node) &&
+    node.typeParameters &&
+    t.isIdentifier(node.typeName) &&
+    BUILTIN_GENERICS.has(node.typeName.name)
+  ) {
+    visitIdentifiers(node.typeParameters, visit)
+    return
+  }
   if (t.isIdentifier(node)) {
     visit(node)
     return

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

Comment thread src/groqFiles/groqModule.ts Resolved
@cursor
cursor Bot force-pushed the cody/groq-file-types-3a53 branch 2 times, most recently from 893f525 to ef5111b Compare October 22, 2026 19:56

@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 is ON. A cloud agent has been kicked off to fix the reported issue. You can view the agent here.

Reviewed by Cursor Bugbot for commit ef5111b. Configure here.

Comment thread src/groqFiles/inferParams.ts Resolved
Exports the class that turns a schema and groq-js type nodes into
TypeScript types, with generateTsType public, so a tool that types GROQ
outside TypeGenerator produces the same output sanity typegen generate
writes. @sanity-labs/groq-content-mapper, the TypeScript 7.1 content
mapper for .groq files, constructs it from a schema, calls
generateTsType on query results and inferred params, and iterates it
for the schema type aliases a result refers to.

Marked @beta. Nothing else in the public API changes.
@cursor
cursor Bot force-pushed the cody/groq-file-types-3a53 branch from 24e223f to dc45b45 Compare October 22, 2026 22:07
@cursor cursor Bot changed the title feat: type .groq file imports with a TypeScript 7.1 content mapper feat: export SchemaTypeGenerator Oct 22, 2026
stipsan added a commit to sanity-labs/groq-content-mapper that referenced this pull request Oct 22, 2026
Ports the content mapper and the runtime entries (register, vite, jest,
next) from sanity-io/codegen#176 and #177 into a standalone package that
depends on @sanity/codegen for type generation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
cursor Bot pushed a commit to sanity-labs/groq-content-mapper that referenced this pull request Oct 22, 2026
Ports the content mapper and the runtime entries (register, vite, jest,
next) from sanity-io/codegen#176 and #177 into a standalone package that
depends on @sanity/codegen for type generation.
cursor Bot pushed a commit to sanity-io/template-nextjs-personal-website that referenced this pull request Oct 22, 2026
…content mapper

Try sanity-io/codegen#176 + #177, sanity-io/client#1373 and
sanity-io/next-sanity#3961 end to end: every query moves into its own
.groq file, imported as
`import q, {type QueryResponse} from './x.groq' with {type: 'text'}`,
with the result and params inferred by `sanityFetch` / `client.fetch`
with no generic argument and no global `SanityQueries` registry.

- tsconfig.json registers the `@sanity/codegen` content mapper for .groq
  against ./schema.json; type-check runs tsc 7.1 with --runExternalCode
- next.config.ts wraps the config in `withGroqFiles`, which re-runs the
  production type check with that flag
- next-sanity's preview types `sanityFetch` params from the brand, so the
  cache wrappers pass `ClientParams<QueryString>` straight through
- `ProjectDuration` is a client component typed by a type-only .groq
  import; the query text stays out of the client chunks
- the generated query types and the hand-written `SlugsByTypeQueryParams`
  go away; the mapper infers `{type: 'page' | 'project'}` itself

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
@stipsan
stipsan marked this pull request as ready for review October 22, 2026 22:21
@stipsan
stipsan merged commit 9a52bd6 into main Oct 22, 2026
8 checks passed
@stipsan
stipsan deleted the cody/groq-file-types-3a53 branch October 22, 2026 22:22
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