Documentation Menu

Generated types

7 min read

Quillet writes one TypeScript declaration file per environment from its components, so the code that reads your content is compiled against the same shapes the CMS enforces on save.

Why a site compiles against the shapes the CMS enforces

The CMS refuses a body that does not fit its component, but a site that reads that body learns its shape only by reading it. When someone renames a field in the builder, a site with hand-written types keeps compiling and shows undefined on the live page. Generated types close that gap. The server writes them out of the very schemas that validate every save, so they cannot drift from what is stored, and nobody maintains them by hand. A build that regenerates them before it compiles turns a renamed field into a compile error, in the place that reads it.

What the types route serves and what the file declares

GET /api/v1/projects/<project>/envs/<env>/types answers a .d.ts file as text/plain. The file is generated from the published version of every component in the environment; ?draft=1 uses the drafts instead. The response also carries Cache-Control: no-store and an ETag taken from the component versions it read. Send that ETag back in If-None-Match, and until a component changes, the route answers 304 with no body. It needs a management credential that may read content, such as a CLI key with content:read. A delivery key is refused with 403.

The file holds these declarations, in this order:

  • a header naming the project and the environment, with "Do not edit.";
  • Ref<T> = { $ref: string } | T, the type of a reference field before and after delivery resolves it;
  • Link and Asset (with ResolvedAsset, the file details delivery puts in place of a pointer), but only where some component uses them;
  • one interface per component, named by the component's name in PascalCase (landing-page becomes LandingPage), with the component's Description as its doc comment;
  • Schemas, a map from every component's name to its interface.

For the cta, hero and landing-page from Components and the builder and Pages, part of the file reads:

/**
 * The block at the top of a landing page.
 */
export interface Hero {
  _component?: 'hero'
  headline: string
  prose?: string
  image?: Asset
  sku?: string
  cta?: Cta
}
export interface LandingPage {
  _component?: 'landing-page'
  title: string
  seo?: {
    title?: string
    description?: string
    noIndex?: boolean
  }
  blocks: ((Hero & {_component: 'hero'}) | (Cta & {_component: 'cta'}))[]
}

export interface Schemas {
  cta: Cta
  hero: Hero
  'landing-page': LandingPage
}

Every interface leads with _component, the component's own name. On its own the field is optional. Inside a block list it is required: each member of the union is the component's interface joined with its name, so switch (block._component) narrows each case to the right fields. The other fields follow the component. A required field is required in the type; an embedded component is its named interface; a group is an inline object type; and a Reference field is Ref<Target>, or Ref<unknown> where it accepts any document.

Names are checked when a component is saved, not when the file is generated. Two components whose names give the same type name cannot both exist, and asset, link, ref, resolved-asset and schemas are taken by the file's own declarations. If the generator still cannot turn a component into a type, the request fails with 422, keyed by the component's name: "This schema cannot be turned into a type: …".

How to

Dashboard

  1. Open the Components tab.
  2. Press Download types in the page header. The menu says "Saved as quillet/types.d.ts in your repository."
  3. Choose Download published types for a build that reads delivery. Choose Download draft types while a site is being written against components that nobody has published yet.
  4. Put the file where your build expects it. The command line below does the same thing on every build, and that is the better habit.

API

Set the variables, with a CLI key that holds content:read:

export QUILLET_API_URL=<cms-url> QUILLET_PROJECT=<project> QUILLET_ENV=<environment>
export QUILLET_MANAGEMENT_KEY=<keyId>:<secret>
E="$QUILLET_API_URL/api/v1/projects/$QUILLET_PROJECT/envs/$QUILLET_ENV"

Take the published types and keep the response headers. Then ask again, sending the ETag they carried:

curl -s -D types.headers -o quillet.d.ts "$E/types" -u "$QUILLET_MANAGEMENT_KEY"
ETAG=$(grep -i '^etag:' types.headers | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" "$E/types" -u "$QUILLET_MANAGEMENT_KEY"

The second request prints 304. Nothing changed, so nothing is sent. For the drafts, add ?draft=1 to the URL.

CLI

With QUILLET_API_URL, QUILLET_PROJECT, QUILLET_ENV and QUILLET_MANAGEMENT_KEY set as in the API tab, or in a .quilletrc, run:

npx quillet types --out src/quillet.d.ts
npx quillet types --out src/quillet.d.ts
npx quillet types --out src/quillet.d.ts --draft

The first run prints wrote and the file's path, and the second prints the path and is up to date. The file's first line records the ETag it was written from, // quillet-types etag: W/"…". The next run sends it back. An unchanged environment then costs a 304 and rewrites nothing. --draft types the drafts instead. Without --out, the file is quillet.d.ts in the working directory.

SDK

The SDK carries the same generator, for a site that has the SDK and not the CLI. It reads the CMS address, the project and the environment from the SDK's own settings. It also needs a CLI key with content:read in QUILLET_MANAGEMENT_KEY, because a delivery key cannot read the management API:

npx quillet-sdk types --out src/quillet.d.ts

Run it before every build, for example as "prebuild": "quillet-sdk types --out src/quillet.d.ts" in package.json. Then read through the client with the type of the component you expect:

// page.ts: read a landing page and draw each block by its component.
import { quillet } from '@quilletcms/sdk/node'
import type { Schemas } from './quillet.js'

const q = quillet()

const page = await q.get<Schemas['landing-page']>('pages/about')

for (const block of page.content.blocks) {
  switch (block._component) {
    case 'hero':
      console.log('hero:', block.headline)
      break
    case 'cta':
      console.log('cta:', block.label, block.link.href ?? block.link.ref)
      break
  }
}

Rename headline in the builder, publish, regenerate, and this code stops compiling at block.headline. Types in the SDK covers the typed client in more depth.

Limits

  • The types describe shapes, not every rule. A string's length, a pattern or a format is checked by the API on save and appears nowhere in the types.
  • minItems and maxItems are left out on purpose. Spelled as tuples, a list of up to twenty blocks would be twenty-one alternatives, and a site that appended a block would stop compiling.
  • Ref<T> does not include null. A reference whose target is missing or unpublished is delivered as null, with "missing": true in _meta.refs, and a missing file is likewise null in the body. Check before you read through one.
  • The file is a snapshot of one environment at one moment. Published types do not include a component that has only been drafted. Regenerate on every build, and use the draft types only while developing.
  • get<T> is a statement about the body, not a check of it. The SDK does not validate content at run time.
  • Labels, help text, roles and the page flag are presentation for the dashboard, and they do not appear in the types.
  • One component the generator cannot type fails the whole request, not just that component's interface.

Last updated