On this page

Schemas and types

import { z } from 'zod'
import { variables } from '@react-markdown-kit/variables'


const ReportSchema = z.object({
  customer: z.object({ name: z.string() }),
  revenue: z.number(),
})


variables({ schema: ReportSchema, data: { customer: { name: 'Acme' }, revenue: 50_000 } })

variables() accepts any validator implementing Standard Schema, the shared interface designed by contributors from Zod, Valibot and ArkType. When you pass schema, data is typed from it, so TypeScript will reject a call that leaves out revenue or misspells the customer key. Without a schema, data is whatever you pass, and you can type it with a generic parameter: variables<ReportData>({ data }).

Generics are compile time, schemas are runtime#

Generic type parameterStandard Schema
When it runstsc, and your editorEvery compilation
CatchesA wrong literal in your own codeA wrong value from an API, a database or a user
Cost at runtimeNoneOne validation pass
Failure modeA type errorAn error diagnostic, and nothing rendered

Data that comes over the network is really unknown, whatever the type says, so I'd recommend validating it.

Valibot and ArkType work the same way, through the same property, with no adapter:

import * as v from 'valibot'
const ReportSchema = v.object({ customer: v.object({ name: v.string() }), revenue: v.number() })
import { type } from 'arktype'
const ReportSchema = type({ customer: { name: 'string' }, revenue: 'number' })

@react-markdown-kit/variables doesn't depend on any of these libraries. It just reads the ~standard property, so the only validator installed is the one you picked.

What a rejection looks like#

If validation fails, resolution stops there. Resolving against rejected data would give you a document that looks fine to publish but is built on bad data, so the document becomes the fallback (nothing by default) and the diagnostics explain why:

// [{ code: 'VARIABLE_SCHEMA_INVALID', severity: 'error', path: 'revenue', message: ... }]

Each validator issue becomes one diagnostic with the data path it's about. Messages include the path but not the value, so they're safe to log.

Resolution is synchronous. A validator that returns a Promise produces VARIABLE_SCHEMA_ASYNC instead of a half-resolved document, so do any async work before you compile.

Schemas stay optional#

Without a schema, a missing value still produces VARIABLE_REQUIRED_VALUE and an empty document. Adding a schema moves that check earlier, so bad data is rejected when it comes in instead of being caught while the document resolves.

Missing data fails instead of half-rendering
Authored sourcenever changes
## Statement for {{customer.name}}

Balance: {{balance | currency:"EUR"}}
Resolved for Completechanges

Statement for Northwind

Balance: €1,240.50

The second dataset fails, so the pane shows diagnostics instead of a document with a blank where the balance should be.

Presentation metadata is separate#

Runtime validation and editor UX are separate concerns, so labels go in variables instead of the schema. That way your validator doesn't need to know about any kit-specific annotations.

variables({
  data,
  schema: ReportSchema,
  variables: {
    'customer.name': { label: 'Customer name', group: 'Customer' },
    revenue: { label: 'Revenue', group: 'Financials' },
  },
})

The same map is used by variableChips({ variables }) in the editor (see Authoring). Two of the fields affect resolution:

variables: {
  'customer.vatNumber': { required: false },
  'report.title': { default: 'Monthly report' },
}

required: false lets a missing value resolve to empty text with a warning. default provides a value when the data doesn't have one, and makes the variable optional. Every other variable is required by default.

Next#

The overview, from placeholder syntax to schemas: Markdown variables.

Last updated on