Skip to main content

Schemas and types

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

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

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

template() accepts any validator implementing Standard Schema, the shared interface designed by contributors from Zod, Valibot and ArkType. When schema is given, data is typed from it, so TypeScript rejects a call that omits revenue or spells the customer key wrong. Without a schema, data is whatever you pass, and a generic parameter types it: template<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 crosses a network boundary is unknown no matter what the type says. Validate it.

Valibot and ArkType work the same way, with the same property and 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/template depends on none of these libraries. It reads the ~standard property structurally, so your validator is the only one installed.

What a rejection looks like

Validation failure is terminal. Resolving against rejected data would produce a publishable-looking document from data nobody vouched for, so the document becomes the fallback (nothing by default) and the diagnostics say why:

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

Each validator issue becomes one diagnostic with the offending data path. Messages carry the path and not the value, so they are safe to log.

Resolution is synchronous. A validator that returns a Promise produces TEMPLATE_SCHEMA_ASYNC rather than a half-resolved document. Do the async work before you compile.

Schemas stay optional

Without a schema, a missing value still produces TEMPLATE_REQUIRED_VALUE and an empty document. A schema upgrades the failure from "the template noticed" to "the boundary rejected it".

Missing data fails, it does not half-render
Authored template never changes
# Statement for {{customer.name}}

Balance: {{balance | currency:"EUR"}}
Resolved for Complete changes

Statement for Northwind

Balance: €1,240.50

Data passed to template()
{
  "customer": {
    "name": "Northwind"
  },
  "balance": 1240.5
}

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

Presentation metadata is separate

Runtime validation and editor UX are different concerns, so labels live in variables rather than in the schema. No validator has to learn kit-specific annotations.

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

The same map feeds templateVariables({ variables }) in the editor, described in Authoring. Two fields change 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 supplies a value when the data has none, and implies the variable is optional. Everything else is required by default.

Next