Types
quillet-sdk types writes a TypeScript declaration for every component in an environment, so a site's code that reads a page knows its fields and is told at compile time when a component changes under it.
Why a renamed field should fail the build
A component describes a shape. A page has a title; a hero has a headline and maybe an image. Suppose an engineer renames a field in the builder. Every line of site code that reads the old name keeps compiling, and then breaks at run time, on a live page. When the types are generated from the components themselves, that break moves to the build, where it is cheap to fix. The command asks the CMS for the declarations, so the types and the components cannot drift apart.
If you are a content manager, this is why an engineer may ask you to hold off publishing a change to a component until the site is ready for it. The site's types are made from the published components, and the next build checks the site against whatever change you published.
What the command writes and when it skips
npx quillet-sdk types finds the project and environment the way the client does, from .quilletrc or quillet.config.* and the environment. It then asks the management API for the environment's declarations. Because it calls the management API, it needs a CLI key rather than a delivery key. Set QUILLET_MANAGEMENT_KEY to <keyId>:<secret> of a key that holds content:read. No delivery key is needed for this command.
| Flag | Meaning | Default |
|---|---|---|
--out <file> |
Where to write the declaration. | quillet.d.ts |
--draft |
Type the components' current drafts rather than their published versions. | off |
The first line of the file records the answer's ETag, and the next run sends it back. If no component has changed, the CMS answers 304, and the command prints <file> is up to date and writes nothing. A build that runs the command every time therefore pays almost nothing for it. On any failure the command exits 1 with the CMS's message, and that stops a prebuild.
The declaration holds these types:
- An interface per component, named from its key, so
herobecomesHero. Each interface starts with an optional_componentholding the component's name. - A
Schemasmap from every component name to its interface, so a page readsSchemas['page']. Ref<T>, which is{ $ref: string } | T, wherever a field is a reference. What delivery answers there depends on how deeprefsreached.Asset, which is{ $asset: string, … } | ResolvedAsset, wherever a field holds a file.- A block list, typed as a union of intersections. Each member has its
_componentrequired, which makes aswitchon_componentexhaustive.
generateTypes({ out }) from @quilletcms/sdk/node is the same command as a function, for a build script that wants the result.
How to
Mint a CLI key for the build under Settings → Keys → New key with the scope content:read. With the client's settings already in .quilletrc or the environment, run the command once:
export QUILLET_MANAGEMENT_KEY=<keyId>:<secret>
npx quillet-sdk types --out src/quillet.d.ts
It prints wrote and the file's path. Run it a second time and it prints that the file is up to date.
Run it before every build. A component change that would break the site then breaks the build instead:
{ "scripts": { "prebuild": "quillet-sdk types --out src/quillet.d.ts", "build": "next build" } }
Read a document as the interface of its component. In a Next.js project whose @/ alias points at src/, you import the generated file as @/quillet:
// lib/home.ts: the home page, typed by its component.
import type { Schemas } from '@/quillet'
import { quillet } from '@quilletcms/sdk/node'
const q = quillet()
const home = await q.get<Schemas['page']>('pages/home')
console.log(home.content.title)
console.log(home.content.hero?.headline ?? 'no hero')
Render a block list with a switch that the compiler checks. When someone adds a new component to the list in the builder, the never line fails to compile until you handle it:
// lib/sections.ts: one renderer per block, and no block forgotten.
import type { Schemas } from '@/quillet'
type Section = NonNullable<Schemas['page']['sections']>[number]
export function render(section: Section): string {
switch (section._component) {
case 'hero':
return `<h2>${section.headline}</h2>`
case 'cta':
return `<a class="button" href="${section.href}">${section.label}</a>`
default: {
const unhandled: never = section
throw new Error(`no renderer for ${JSON.stringify(unhandled)}`)
}
}
}
A field that holds a file is typed as delivery's wire form. What get hands you, though, is the SDK's Asset, which adds originUrl and visibility and has its url decided by assets.mode. Narrow the field with isAsset, then use it as the SDK's type:
// lib/image.ts: from the generated Asset to the SDK's, for srcset and friends.
import { isAsset, type Asset } from '@quilletcms/sdk'
import { quillet } from '@quilletcms/sdk/node'
import type { Schemas } from '@/quillet'
const q = quillet()
export function asset(value: unknown): Asset | null {
return isAsset(value) ? (value as Asset) : null
}
const home = await q.get<Schemas['page']>('pages/home')
const image = asset(home.content.hero?.image)
if (image) console.log(q.assets.srcset(image))
A Ref<T> field holds the target's body when delivery inlined it. It holds { $ref } when refs ran out or was 0. isRef tells the two apart:
// lib/ref.ts: use an inlined reference, or say which document it names.
import { isRef } from '@quilletcms/sdk'
import type { Ref } from '@/quillet'
export function inlined<T>(field: Ref<T> | undefined): T | null {
if (field === undefined || isRef(field)) return null
return field
}
You can also generate the types from a script instead of the command line, and report whether anything changed:
// scripts/types.ts: the same generation, as a step of your own build.
import { generateTypes } from '@quilletcms/sdk/node'
const { changed, out } = await generateTypes({ out: 'src/quillet.d.ts' })
console.log(changed ? `wrote ${out}` : `${out} is up to date`)
Limits
- Types are a compile-time promise. Nothing checks a delivered body against them at run time, and
get<T>accepts anyTyou give it. - The generated
Assetdescribes the wire form, not the SDK's converted file. It has nooriginUrlorvisibility, and itsurlis typedstring, although delivery answersnullfor a private file the key may not read. Narrow and convert it as shown above, and checkurl. minItemsandmaxItemsare left out on purpose. Otherwise a list of at most twenty would become twenty-one tuple types. A list's length is a rule the CMS enforces when a document is saved.- The types follow the published components unless you pass
--draft. A component that is drafted in the builder but not published is not in the file. - The command needs a CLI key with
content:read. WithoutQUILLET_MANAGEMENT_KEYit stops before making any request. A delivery key in that variable is refused with 403, "A delivery key can only be used on the delivery API." - There is one file per environment. A site that reads two environments with different components generates two files.
- The Quillet CLI has its own
quillet types, which reads the same route. Use one or the other in a project, not both.
Related
- Generated types: what each kind of field becomes in the declaration.
- Components and the builder: where the shapes come from.
- Pages: block lists and
_component. - Inline and referenced components: when a field is a
Ref<T>. - Reading content:
get<T>and the rest of the client. - Command reference:
quillet typeson the command line.