@sanity-labs/groq-content-mapper
v0.1.11
Published
Typed .groq file imports for TypeScript 7.1, from your Sanity schema
Downloads
2,618
Readme
@sanity-labs/groq-content-mapper
Typed .groq file imports for TypeScript 7.1 and later. A query kept in its own file is imported as a string that carries its result and params types, inferred from your Sanity schema.
Requirements
- TypeScript 7.1 or later. Content mappers do not exist in earlier versions. 7.1 is currently a beta:
npm install -D typescript@beta. - Node.js 22.12 or later. On Node 26.5 and later, run
.groqimports with--experimental-import-text, or with the preload below. On earlier Node, runnode --import @sanity-labs/groq-content-mapper/register. - Type-check with
tsc --runExternalCode. Without that flag the mapper does not run, and.groqimports are not typed.
// package.json
{
"scripts": {
"typecheck": "tsc --noEmit --runExternalCode",
},
}Generate a schema
The mapper reads schema.json, the file sanity schema extract writes from a Sanity Studio config. A minimal sanity.cli.ts names the project and dataset, with schemaExtraction's defaults spelled out:
// sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
api: {projectId: 'myprojectid', dataset: 'production'},
schemaExtraction: {
path: './schema.json', // default
enforceRequiredFields: false, // default
// workspace: 'production', // required if sanity.config.ts exports an array of named workspaces
},
})sanity.config.ts adds the schema types, here a post with the fields the example below uses:
// sanity.config.ts
import {defineConfig, defineType} from 'sanity'
export default defineConfig({
projectId: 'myprojectid',
dataset: 'production',
schema: {
types: [
defineType({
name: 'post',
type: 'document',
fields: [
{name: 'title', type: 'string'},
{name: 'slug', type: 'slug'},
{name: 'publishedAt', type: 'datetime'},
],
}),
],
},
})npx sanity schema extractThis writes schema.json in the current directory by default. Point the mapper's options.schema at that path, as shown under Setup.
Usage
// postsBySearch.groq
*[_type == "post" && title match $q] | order(publishedAt desc) [$start...$end] {
_id,
title,
slug
}import postsBySearch, {type QueryResponse} from './postsBySearch.groq' with {type: 'text'}
const posts = await client.fetch(postsBySearch, {q: 'foo', start: 0, end: 10})
// ^? QueryResponse, with the params checkedTypes come from the file as it is. Nothing is written to disk, and nothing is added to the global scope.
What an import gives you
Each .groq file has three exports:
- The default export is the query string, typed as its own text, and still assignable to
string. The type includes the file's trailing newline, because that is what the import evaluates to. QueryResponseis the result type.[0]isT | null.QueryParamsis the object the$placeholders expect. A placeholder compared with a field takes the field's type,matchtakes a string, slice bounds are numbers, and anything the schema says nothing about isunknown. A query without placeholders hasQueryParams = Record<string, never>.
export type QueryResponse = Array<{_id: string; title: string | null; slug: Slug | null}>
export type QueryParams = {q: string; start: number; end: number}
declare const query: '*[_type == "post" && title match $q] …\n' & {
readonly __groqResult?: QueryResponse
readonly __groqParams?: QueryParams
}
export default queryclient.fetch(query, params) infers both types once @sanity/client includes sanity-io/client#1373. With an older client, pass the type yourself: client.fetch<QueryResponse>(query, params). GroqQuery<Query, Result, Params>, GroqResult<Q> and GroqParams<Q> are exported for the same shape.
Imports
A value import needs with {type: 'text'}, the standard text import. At run time the default export is the file's content.
A type-only import needs no attribute. It is erased, so the query string stays out of the bundle:
import type {QueryResponse} from './postsBySearch.groq'
export function PostList(props: {posts: QueryResponse}) {}When a file needs both the query and its types, write one statement:
import postsBySearch, {type QueryResponse} from './postsBySearch.groq' with {type: 'text'}A separate import type from the same file gets merged by ESLint's import-x/no-duplicates autofix, which drops or mangles the attribute.
Setup
npm install -D @sanity-labs/groq-content-mapper typescript@betaIf the app runs .groq imports on Node through the preload (rather than as a bundle), install the package as a regular dependency instead.
npx sanity schema extractschema is required, and the tsconfig is the only configuration. Paths are relative to it:
// tsconfig.json
{
"compilerOptions": {/* ... */},
"contentMappers": [
{
"package": "@sanity-labs/groq-content-mapper",
"extensions": [".groq"],
"options": {"schema": "./schema.json"},
},
],
}Type-check with the tsc --noEmit --runExternalCode script above. The schema file is watched, so extracting it again retypes the imports. A query that does not parse, or a missing schema, still resolves: the unknown parts are unknown, and the diagnostic says why. A GROQ syntax error is reported on the .groq file as error groq2.
Editor
In VS Code or Cursor, install the TypeScript 7 extension (TypeScriptTeam.native-preview) and point it at the project's TypeScript. The extension bundles 7.0:
// .vscode/settings.json
{
"js/ts.experimental.useTsgo": true,
"js/ts.tsdk.path": "./node_modules/typescript",
}Trust the workspace, and allow the extension to use the workspace's TypeScript when it asks.
One schema per folder
A codebase that queries more than one project or dataset has one schema per folder. TypeScript allows one mapper entry per extension, so list the folders inside that entry with schemas:
{
"contentMappers": [
{
"package": "@sanity-labs/groq-content-mapper",
"extensions": [".groq"],
"options": {
"schemas": [
{"include": "src/queries/blog", "schema": "schemas/blog.json"},
{"include": ["src/queries/shop", "src/queries/checkout"], "schema": "schemas/shop.json"},
],
},
},
],
}A file uses the schema of the deepest folder that contains it, and schema when no folder does. Without a schema, a file outside every folder gets a diagnostic.
include also takes globs, with / as the separator on every platform:
{"include": ["src/**/blog/*.groq", "src/*/shop"], "schema": "schemas/shop.json"}A glob that matches a folder includes everything under it, so src/*/shop covers src/eu/shop/deep/products.groq. For "deepest", a glob counts as its folder before the first wildcard: src/**/blog/*.groq is as deep as src. Of two entries that are equally deep, the first listed wins. A folder whose name only looks like a glob, such as app/[locale], still includes everything under it.
A package.json imports alias resolves to the file, so the folder's schema still applies. Node, Bun, Deno and the bundlers resolve imports themselves:
// package.json
{
"imports": {
"#queries/*": "./src/queries/*",
},
}import posts from '#queries/blog/posts.groq' with {type: 'text'}
import products from '#queries/shop/products.groq' with {type: 'text'}Running the imports
with {type: 'text'} makes the runtime or bundler deliver the file as a string. Where one does not, one line fills in.
| Runtime or bundler | Setup |
| --------------------------------------------- | ----------------------------------------------------------------------- |
| Bun, Deno, esbuild, Bun.build | nothing |
| webpack 5.102 and later, Rspack 1.7 and later | nothing |
| webpack before 5.102 | {test: /\.groq$/, type: 'asset/source'} in module.rules |
| Rolldown | moduleTypes: {'.groq': 'text'} |
| Node 26.5 and later | --experimental-import-text, or the preload below |
| Node 22.12 and later | node --import @sanity-labs/groq-content-mapper/register main.ts |
| Vite, Vitest, Rollup | the Vite plugin |
| Sanity Studio, App SDK apps | the Vite plugin, in vite: {plugins: [groqFiles()]} of sanity.cli.ts |
| Next.js 16.3 and later | withGroqFiles |
| Jest | the Jest transformer, with tests compiled to CommonJS |
Node
node --import @sanity-labs/groq-content-mapper/register main.tsVite, Vitest, Rollup
// vite.config.ts
import groqFiles from '@sanity-labs/groq-content-mapper/vite'
import {defineConfig} from 'vite'
export default defineConfig({plugins: [groqFiles()]})Vite's dev server does not handle the attribute, so the plugin is needed there even when a production build would work without it.
Next.js
// next.config.ts
import {withGroqFiles} from '@sanity-labs/groq-content-mapper/next'
export default withGroqFiles(nextConfig)withGroqFiles covers both Turbopack and the webpack Next bundles. It also replaces the type check next build runs, which does not pass --runExternalCode and so fails on every .groq import. The same check runs with the flag after the compile. If the config already sets typescript.ignoreBuildErrors, no check is run.
Jest
// jest.config.js
export default {
transform: {'\\.groq$': '@sanity-labs/groq-content-mapper/jest'},
}Jest rejects type: 'text' in its ES module loader, so this is for tests compiled to CommonJS, where the attribute is dropped.
Limitations
- A comparison against
^inside a filter or[]{...}over non-object elements, such astags[@ == ^.title], types its placeholder asunknown. includehas no negated globs:!legacyis the folder named!legacy. Use a deeper entry to give a subfolder another schema.
License
MIT
