Documentation Menu

Types

7 min read

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 hero becomes Hero. Each interface starts with an optional _component holding the component's name.
  • A Schemas map from every component name to its interface, so a page reads Schemas['page'].
  • Ref<T>, which is { $ref: string } | T, wherever a field is a reference. What delivery answers there depends on how deep refs reached.
  • 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 _component required, which makes a switch on _component exhaustive.

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 any T you give it.
  • The generated Asset describes the wire form, not the SDK's converted file. It has no originUrl or visibility, and its url is typed string, although delivery answers null for a private file the key may not read. Narrow and convert it as shown above, and check url.
  • minItems and maxItems are 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. Without QUILLET_MANAGEMENT_KEY it 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.

Last updated