groqd
is a schema-unaware, runtime-safe query builder for GROQ. The goal of groqd
is to give you (most of) the flexibility of GROQ, with the runtime/type safety of Zod and TypeScript.
groqd
works by accepting a series of GROQ operations, and generating a query to be used by GROQ and a Zod schema to be used for parsing the associated GROQ response.
An illustrative example:
import { q } from "groqd";
// Get all of the Pokemon types, and the Pokemon associated to each type.
const { query, schema } = q("*")
.filter("_type == 'poketype'")
.grab({
name: q.string(),
pokemons: q("*")
.filter("_type == 'pokemon' && references(^._id)")
.grab({ name: q.string() }),
});
// Use the schema and the query as you see fit, for example:
const response = schema.parse(await sanityClient.fetch(query));
// At this point, response has a type of:
// { name: string, pokemons: { name: string }[] }[]
// ππ
Since the primary use-case for groqd
is actually executing GROQ queries and validating the response, we ship a utility to help you make your own fetching function. Here's an example of wrapping @sanity/client
's fetch function:
import sanityClient from "@sanity/client";
import { q, makeSafeQueryRunner } from "groqd";
const client = sanityClient({
/* ... */
});
// π Safe query runner
export const runQuery = makeSafeQueryRunner((query) => client.fetch(query));
// ...
// π Now you can run queries and `data` is strongly-typed, and runtime-validated.
const data = await runQuery(
q("*").filter("_type == 'pokemon'").grab({ name: q.string() }).slice(0, 150)
);
// data: { name: string }[]
Using makeSafeQueryRunner
is totally optional; you might find using q().schema
and q().query
in your own abstractions works better for you.
Prior to version 0.3.0, groqd
provided a pipeline API. However, there were some major drawbacks to that API. We've migrated to a builder pattern API that looks similar to the previous API, but using a builder pattern (instead of piping).
The core difference is instead of feeding all arguments to q()
, q()
accepts a single argument and then you chain method calls on that. For example:
// previously...
q("*", q.filter("_type == 'pokemon'"), q.grab({ name: q.string() }));
// NOW!
q("*").filter("_type == 'pokemon'").grab({ name: q.string() });
These API changes allow us to provide a substantially better TS experience for the end user, and makes the library much easier to maintain and contribute to.
GROQ's primary use is with Sanity. Sanity's Content Lake is fundamentally unstructured, and GROQ (and Sanity's GROQ API) do not have any sort of GraqhQL-like type contracts.
We'd love to see advanced codegen for Sanity and GROQ. However, the end-result would likely not be as runtime type-safe as some might desire due to the flexibility of Sanity's Content Lake and the GROQ language in general.
The goal of groqd
is to work around these constraints by allowing you to specify the runtime data schema for your query so that your data's type is runtime safe β not just theoretically, but empirically.
groqd
uses a builder pattern for building queries. Builder instances are created with a function q
, and are chainable. There are four internal classes that are used as part of the query builder process: UnknownQuery
, ArrayQuery
, UnknownArrayQuery
, and EntityQuery
. These four classes have some overlap, but generally only contain methods that "make sense" for the type of result they represent (e.g. ArrayQuery
will contain methods that an EntityQuery
will not, such as filtering and ordering).
The entry point for the query builder, which takes a base query as its sole argument (such as "*"
). Returns an UnknownResult
instance to be built upon.
const { query, schema } = q("*").filter("_type == 'pokemon'");
This function is best used in conjunction with a "query runner" from makeSafeQueryRunner
, such as:
import sanityClient from "@sanity/client";
import { q, makeSafeQueryRunner } from "groqd";
// Wrap sanityClient.fetch
const client = sanityClient({
/* ... */
});
export const runQuery = makeSafeQueryRunner((query) => client.fetch(query));
// Now you can fetch your query's result, and validate the response, all in one.
const data = await runQuery(q("*").filter("_type == 'pokemon'"));
Available on UnknownQuery
, ArrayQuery
, and EntityQuery
, handles projections, or selecting fields from an existing set of documents. This is the primary mechanism for providing a schema for the data you expect to get.
q.grab
accepts a "selection" object as its sole argument, with three different forms:
q("*").grab({
// projection is `{ "name": name }`, and validates that `name` is a string.
name: ["name", q.string()],
// shorthand for `description: ['description', q.string()]`,
// projection is just `{ description }`
description: q.string(),
// can also pass a sub-query for the field,
// projection is `{ "types": types[]->{ name } }`
types: q("types").filter().deref().grab({ name: q.string() }),
});
See Schema Types for available schema options, such as q.string()
. These generally correspond to Zod primitives, so you can do something like:
q("*").grab({
name: q.string().optional().default("no name"),
});
Groq offers a select
operator that you can use at the field-level to conditionally select values, such as the following.
q("*").grab({
strength: [
"select(base.Attack > 60 => 'strong', base.Attack <= 60 => 'weak')",
q.union([q.literal("weak"), q.literal("strong")]),
],
});
However, in real-world practice it's common to have an array of values of varying types and you want to select different values for each type. .grab
allows you to do conditional selections by providing a second argument of the shape {[condition: string]: Selection}
.
This second argument is not as flexible as the =>
operator or select
function in GROQ, and instead provides a way to "fork" a portion of your selection (e.g., only the base selection and one of the conditional selections will be made at any give time). Here's an example.
q("*")
// Grab _id on all pokemon
.grab(
{
_id: q.string(),
},
{
// And for Bulbasaur, grab the HP
"name == 'Bulbasaur'": {
name: q.literal("Bulbasaur"),
hp: ["base.HP", q.number()],
},
// And for Charmander, grab the Attack
"name == 'Charmander'": {
name: q.literal("Charmander"),
attack: ["base.Attack", q.number()],
},
}
);
// The query result type looks something like this:
type QueryResult = (
| { _id: string; name: "Bulbasaur"; hp: number }
| { _id: string; name: "Charmander"; attack: number }
| { _id: string }
)[];
In real-world Sanity use-cases, it's likely you'll want to "fork" based on a _type
field (or something similar).
Important! In the example above, if you were to add name: q.string()
to the base selection, it would break TypeScript's ability to do discriminated union type narrowing. This is because if you have a type like {name: "Charmander"} | {name: string}
there is no way to narrow types based on the name
field (since for discriminated unions to work, the field must have a literal type).
Just like .grab
, but uses the nullToUndefined
helper outlined below to convert null
values to undefined
which makes writing queries with "optional" values a bit easier.
q("*")
.filter("_type == 'pokemon'")
.grab$({
name: q.string(),
// π `foo` comes in as `null`, but gets preprocessed to `undefined` so we can use `.optional()`.
foo: q.string().optional().default("bar"),
})
Similar to q.grab
, but for "naked" projections where you just need a single property (instead of an object of properties). Pass a property to be "grabbed", and a schema for the expected type.
q("*").filter("_type == 'pokemon'").grabOne("name", q.string());
// -> string[]
Just like .grabOne
, but uses the nullToUndefined
helper outlined below to convert null
values to undefined
which makes writing queries with "optional" values a bit easier.
q("*")
.filter("_type == 'pokemon'")
.grabOne$("name", q.string().optional());
Receives a single string argument for the GROQ filter to be applied (without the surrounding [
and ]
). Applies the GROQ filter to the query and adjusts schema accordingly.
q("*").filter("_type == 'pokemon'");
// translates to: *[_type == 'pokemon']
Receives a list of ordering expression, such as "name asc"
, and adds an order statement to the GROQ query.
q("*").filter("_type == 'pokemon'").order("name asc");
// translates to *[_type == 'pokemon']|order(name asc)
Creates a slice operation by taking a minimum index and an optional maximum index.
q("*").filter("_type == 'pokemon'").grab({ name: q.string() }).slice(0, 8);
// translates to *[_type == 'pokemon']{name}[0..8]
// -> { name: string }[]
The second argument can be omitted to grab a single document, and the schema/types are updated accordingly.
q("*").filter("_type == 'pokemon'").grab({ name: q.string() }).slice(0);
// -> { name: string }
Used to apply the de-referencing operator ->
.
q("*")
.filter("_type == 'pokemon'")
.grab({
name: q.string(),
// example of grabbing types for a pokemon, and de-referencing to get name value.
types: q("types").filter().deref().grabOne("name", q.string()),
});
Used to pipe a list of results through the score
GROQ function.
// Fetch first 9 Pokemon's names, bubble Char* (Charmander, etc) to the top.
q("*")
.filter("_type == 'pokemon'")
.slice(0, 8)
.score(`name match "char*"`)
.order("_score desc")
.grabOne("name", z.string());
A method on the base query class that allows you to mark a query's schema as nullable β in case you are expecting a potential null value.
q("*")
.filter("_type == 'digimon'")
.slice(0)
.grab({ name: q.string() })
.nullable(); // π we're okay with a null value here
The .grab
and .grabOne
methods are used to "project" and select certain values from documents, and these are the methods that dictate the shape of the resulting schema/data. To indicate what type specific fields should be, we use schemas provided by the groqd
library, such as q.string
, q.number
, q.boolean
, and so on.
For example:
q("*")
.filter("_type == 'pokemon'")
.grab({
// string field
name: q.string(),
// number field
hp: ["base.HP", q.number()],
// boolean field
isStrong: ["base.Attack > 50", q.boolean()],
});
The available schema types are shown below.
q.string
, corresponds to Zod's string type.q.number
, corresponds to Zod's number type.q.boolean
, corresponds to Zod's boolean type.q.literal
, corresponds to Zod's literal type.q.union
, corresponds to Zod's union type.q.date
, which is a custom Zod schema that can acceptDate
instances or a date string (and it will transform that date string to aDate
instance). Warning: Date objects are not serializable, so you might end up with a data object that can't be immediately serialized β potentially a problem if usinggroqd
in e.g. a Next.js backend data fetch.q.null
, corresponds to Zod's null type.q.undefined
, corresponds to Zod's undefined type.q.array
, corresponds to Zod's array type.q.object
, corresponds to Zod's object type.q.contentBlock
, a custom Zod schema to match Sanity'sblock
type, helpful for fetching data from a field that uses Sanity's block editor. For example:Pass an object of the shapeq("*") .filter("_type == 'user'") .grab({ body: q.array(q.contentBlock()) });
{ markDefs: z.ZodType }
toq.contentBlock
to specify custom markdef types, useful if you have custom markdefs, e.g.:q("*") .filter("_type == 'user'") .grab({ body: q.array(q.contentBlock({ markDefs: q.object({ _type: q.literal("link"), href: q.string() }) })) });
q.contentBlocks
, a custom Zod schema, to match a list ofq.contentBlock
's. Pass an argument of the shape{ markDefs: z.ZodType }
to specify custom markdef types.q("*") .filter("_type == 'user'") .grab({ body: q.contentBlocks() });
GROQ will return null
if you query for a value that does not exist. This can lead to confusion when writing queries, because Zod's .optional().default("default value")
doesn't work with null values. groqd
ships with a nullToUndefined
method that will preprocess null
values into undefined
to smooth over this rough edge.
q("*")
.filter("_type == 'pokemon'")
.grab({
name: q.string(),
// π Missing field, allow us to set a default value when it doesn't exist
foo: nullToUndefined(q.string().optional().default("bar")),
})
The nullToUndefined
helper can also accept a Selection
object to apply to an entire selection.
q("*")
.filter("_type == 'pokemon'")
.grab(nullToUndefined({
name: q.string(),
foo: q.string().optional().default("bar"),
}))
Although we recommend just using .grab$
in this case.
A convenience method to make it easier to generate image queries for Sanity's image type. Supports fetching various info from both image
documents and asset
documents.
In its simplest form, it looks something like this:
q("*")
.filter("_type == 'pokemon'")
.grab({
cover: q.sanityImage("cover"), // π just pass the field name
});
// -> { cover: { _key: string; _type: string; asset: { _type: "reference"; _ref: string; } } }[]
which will allow you to fetch the minimal/basic image document information.
If you have an array of image documents, you can pass isList: true
to an options object as the second argument to q.sanityImage
method.
q("*")
.filter("_type == 'pokemon'")
.grab({
images: q.sanityImage("images", { isList: true }), // π fetch as a list
});
// -> { images: { ... }[] }[]
Sanity's image document has fields for crop information, which you can query for with the withCrop
option.
q("*")
.filter("_type == 'pokemon'")
.grab({
cover: q.sanityImage("cover", { withCrop: true }), // π fetch crop info
});
// -> { cover: { ..., crop: { top: number; bottom: number; left: number; right: number; } | null } }[]
Sanity's image document has fields for hotspot information, which you can query for with the withHotspot
option.
q("*")
.filter("_type == 'pokemon'")
.grab({
cover: q.sanityImage("cover", { withHotpot: true }), // π fetch hotspot info
});
// -> { cover: { ..., hotspot: { x: number; y: number; height: number; width: number; } | null } }[]
Sanity allows you to add additional fields to their image documents, such as alt text or descriptions. The additionalFields
option allows you to specify such fields to query with your image query.
q("*")
.filter("_type == 'pokemon'")
.grab({
cover: q.sanityImage("cover", {
// π fetch additional fields
additionalFields: {
alt: q.string(),
description: q.string(),
},
}),
});
// -> { cover: { ..., alt: string, description: string } }[]
Sanity's image documents have a reference to an asset document that contains a whole host of information relative to the uploaded image asset itself. The q.sanityImage
will allow you to dereference this asset document and query various fields from it.
You can pass an array to the withAsset
option to specify which fields you want to query from the asset document:
- pass
"base"
to query the base asset document fields, includingextension
,mimeType
,originalFilename
,size
,url
, andpath
. - pass
"dimensions"
to query the asset document'smetadata.dimensions
field, useful if you need the image's original dimensions or aspect ratio. - pass
"location"
to query the asset document'smetadata.location
field. - pass
"lqip"
to query the asset'smetadata.lqip
(Low Quality Image Placeholder) field, useful if you need to display LQIPs. - pass
"hasAlpha"
to query the asset'smetadata.hasAlpha
field, useful if you need to know if the image has an alpha channel. - pass
"isOpaque"
to query the asset'smetadata.isOpaque
field, useful if you need to know if the image is opaque. - pass
"blurHash"
to query the asset'smetadata.blurHash
field, useful if you need to display blurhashes. - pass
"palette"
to query the asset document'smetadata.palette
field, useful if you want to use the image's color palette in your UI.
An example:
q("*")
.filter("_type == 'pokemon'")
.grab({
cover: q.sanityImage("cover", {
withAsset: ["base", "dimensions"],
}),
});
// -> { cover: { ..., asset: { extension: string; mimeType: string; ...; metadata: { dimensions: { aspectRatio: number; height: number; width: number; }; }; }; } }[]
A wrapper around q
so you can easily use groqd
with an actual fetch implementation.
Pass makeSafeQueryRunner
a "query executor" of the shape type QueryExecutor = (query: string, ...rest: any[]) => Promise<any>
, and it will return a "query runner" function. This is best illustrated with an example:
import sanityClient from "@sanity/client";
import { q } from "groqd";
// Wrap sanityClient.fetch
const client = sanityClient({
/* ... */
});
export const runQuery = makeSafeQueryRunner((query) => client.fetch(query));
// π Now you can run queries and `data` is strongly-typed, and runtime-validated.
const data = await runQuery(
q("*").filter("_type == 'pokemon'").grab({ name: q.string() }).slice(0, 150)
);
In Sanity workflows, you might also want to pass e.g. params to your client.fetch
call. To support this, add additional arguments to your makeSafeQueryRunner
argument's arguments as below.
// ...
export const runQuery = makeSafeQueryRunner(
// π add a second arg
(query, params: Record<string, unknown> = {}) => client.fetch(query, params)
);
const data = await runQuery(
q("*").filter("_type == 'pokemon' && _id == $id").grab({ name: q.string() }),
{ id: "pokemon.1" } // π and optionally pass them here.
);
A type utility to extract the TypeScript type for the data expected to be returned from the query.
import { q } from "groqd";
import type { InferType } from "groqd";
const query = q("*").grab({ name: q.string(), age: q.number() });
type Persons = InferType<typeof query>; // -> { name: string; age: number; }[]
A type utility to extract the TypeScript type for a selection, useful if extracting .grab
selections into their own constants for re-use and you want the TypeScript type that will come with it. Also useful if you're using conditional selections with .grab
and want to split out each conditional selection into its own const
and get the expected type from that.
import { q } from "groqd";
import type { TypeFromSelection, Selection } from "groqd";
// TextBlock.tsx
const TextBlock = (props: TypeFromSelection<typeof textBlockSelection>) => { /* ... */ };
export const textBlockSelection = {
_type: q.literal("textBlock"),
text: q.string(),
} satisfies Selection;
// somewhere else
import { q } from "groqd";
import { textBlockSelection } from "./TextBlock";
const { schema } = q("*")
.filter("_type == 'blockPage'")
.grab({
content: q("content").grab({}, {
// π using `textBlockSelection` in a conditional selection
"_type == 'blockText'": textBlockSelection,
})
});
Yes! You can write a coalesce expression just as if it were a field expression. Here's an example with groqd
:
q("*")
.filter("_type == 'pokemon'")
.grab({
name: q.string(),
// using `coalesce` in a `grab` call
strength: ["coalesce(strength, base.Attack, 0)", q.number()],
});