Skip to main content

Typed template data

A template reads fields from data. When your code sends invoice.numbr instead of invoice.number, the render still succeeds and prints an empty cell. Typed template data moves that mistake to your compiler: formfeed types generates one type per template from its data schema, and the SDKs check data against it.

await client.renders.create({
template: 'invoice',
data: {
number: 'RE-2026-001',
status: 'sent',
customer: { nam: 'Fennlor' },
items: [],
},
// ~~~ compile error: InvoiceCustomer has no field "nam"
});

Generate the types​

Run formfeed types in a project (the folder with formfeed.json):

npx formfeed types # TypeScript: formfeed.d.ts in the project root
npx formfeed types --lang python # Python: formfeed_templates.py
npx formfeed types invoice delivery-note --out src/formfeed.d.ts

Without slugs every template of the project is included. The generated file needs no runtime code of its own: the TypeScript file only declares types, the Python module only imports typing, typing_extensions (installed with the formfeed package) and formfeed. Commit it, and run the command again whenever a template's data changes.

Set the defaults once in formfeed.json, so every developer and CI generates the same file:

{
"workspace": "fennlor/production",
"types": { "lang": "ts", "out": "src/formfeed.d.ts" }
}
FieldMeaning
types.langts (default) or python
types.outOutput file relative to the project root; formfeed.d.ts or formfeed_templates.py when left out
types.sdkModuleThe module the TypeScript file augments; @formfeed/sdk by default. Set it when you import the SDK under another name.

The same options exist as flags (--lang, --out, --sdk-module); a flag wins over formfeed.json.

Where the schema comes from​

For each template, formfeed types takes the first of these that exists:

ModeSource
In a projecttemplates/<slug>/schema.json; otherwise a schema inferred over all data sets in templates/<slug>/data/*.json
Outside a project, or with --remote or --channel <name>The API: the stored data_schema of the version the channel renders (published by default); otherwise a schema inferred from that version's sample data and data sets

When the API is the source and you name no slugs, every template of the workspace is included; a template that was never published is described by its latest draft, and with --channel a template without that channel is skipped. A slug you name must have a version on the channel.

The header of the generated file says per template where its types come from:

// invoice: stored schema of version 4 (channel published)
// delivery-note: inferred from sample data, every field optional: store a schema to make this a contract

Inferred schemas make every field optional. Sample data shows which fields exist, not which ones the template cannot do without, so the generated type accepts data that leaves any of them out. It still catches misspelled fields and wrong types. To get required fields, store a schema.

Check for drift in CI​

--check writes nothing. It compares the file on disk with what would be generated and exits with code 1 when they differ, so a pull request that changes a schema or a data set without regenerating the types fails:

- run: npx formfeed types --check --ci

Line endings do not count as a difference, so a checkout on Windows passes. With --json the result lists each template with its source and version.

Use the types in TypeScript​

The generated formfeed.d.ts augments the SDK's FormfeedTemplates interface:

// formfeed.d.ts (generated)
declare module '@formfeed/sdk' {
interface FormfeedTemplates {
'delivery-note': DeliveryNoteData;
invoice: InvoiceData;
}
}

export interface InvoiceData {
/** Invoice number as printed */
number: string;
status: 'draft' | 'sent' | 'paid';
customer: InvoiceCustomer;
items: InvoiceItem[];
tags?: string[];
}

Make sure the file is part of your TypeScript program (inside include of your tsconfig.json). From then on renders.create and renders.batch check data for every template the file knows:

import { Formfeed, type TemplateData } from '@formfeed/sdk';

const client = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });

// data is InvoiceData: a missing required field or a wrong type is a compile error
await client.renders.create({
template: 'invoice',
data: {
number: 'RE-2026-001',
status: 'sent',
customer: { name: 'Fennlor' },
items: [],
},
});

// every item's data is checked against the batch's template
await client.renders.batch({
template: 'invoice',
items: invoices.map((data) => ({ data })),
});

// the data type of one template, for your own functions
function invoiceData(order: Order): TemplateData<'invoice'> {
/* … */
}

What stays untyped, so upgrading the SDK or regenerating never breaks a build:

  • a slug the generated file does not know, and a template passed by its tpl_… id;
  • a template name in a string variable rather than a literal;
  • html and url renders.

These keep data?: Record<string, unknown>. The SDK also exports FormfeedTemplates, TemplateData<T> and TypedRenderRequest<T> for wrappers of your own.

Use the types in Python​

The generated module holds a TypedDict per template and a wrapper with one method per template, named after the slug (delivery-note becomes delivery_note):

from formfeed import Formfeed
from formfeed_templates import InvoiceData, typed

client = Formfeed(api_key)
renders = typed(client)

data: InvoiceData = {"number": "RE-2026-001", "status": "sent", "customer": {"name": "Fennlor"}, "items": []}
render = renders.invoice(data, filename="invoice.pdf", mode="async")

Each method takes the template's data as its first argument and passes every other keyword argument to client.renders.create, so it returns the same Render. For AsyncFormfeed, use typed_async(client) and await the methods. Optional fields are NotRequired[...]; mypy and Pyright report a missing required key, an unknown key or a wrong type. The types are only checked by your type checker; at runtime the data is a plain dict. To check data on the server, send validate_data=True with the render; see Check data on the server.

Make a schema the contract​

A stored data schema is a promise about the data a template accepts. It makes the generated fields required where the schema says so, it is what validate_data checks against, and it is what the schema check compares before a version goes live: only stored schemas can stop a breaking change. Publishing a version (or moving a channel to it) whose stored schema breaks callers of the version it replaces is refused with 409 schema_breaking_change unless you allow it (allow_breaking, see Going ahead anyway); where either side has only an inferred schema, the check only reports the change.

Open the Data and schema panel of the editor. The schema section lists every field with its type, whether the template uses it, and which fields the template reads that the data does not contain.

  1. Choose Store as contract. The editor infers one schema over all data sets: a field that every data set contains starts as required, one that only some contain as optional.
  2. Go through the Required column: switch it on for every field the template cannot do without, and off for every field it can.
  3. Save. The schema is stored with the next version.

Update from data infers the contract again after you changed the data sets (your Required choices start over), and Remove contract deletes it with the next version. The editor edits types and required; for anything beyond that (formats, patterns, enums), write schema.json with the CLI, or send data_schema with a version through the API (POST /templates/{id}/versions).

What the generator understands​

Objects with properties and required; arrays with items; string, number, integer, boolean and null; enum and const; anyOf and oneOf as unions, allOf as an intersection; type arrays; local $ref into $defs (or definitions). additionalProperties given as a schema adds an index signature ([key: string]: unknown in TypeScript); true or no additionalProperties adds nothing, so a misspelled key in an object literal stays an error. title, description, examples and format become doc comments. Keywords a type cannot express (not, if/then/else, patternProperties, prefixItems and similar) become unknown or Any with a comment that names them. Nested objects get names from the slug and the property path (InvoiceCustomer, InvoiceItem for the elements of items).

Check data on the server​

Types catch mistakes in code you compile. To have Formfeed check the data of a render itself, against the version's stored schema, send validate_data: true:

curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{"template": "invoice", "validate_data": true, "data": {"number": 42, "customer": {}}}'

The check runs before anything is rendered or counted. Data that does not match is answered with 422 data_validation_error, which lists every mismatch in errors:

{
"type": "https://docs.formfeed.dev/errors/data-validation",
"title": "Data validation failed",
"status": 422,
"code": "data_validation_error",
"detail": "data.items: is required (and 2 more)",
"errors": [
{ "path": "data.items", "message": "data.items: is required" },
{ "path": "data.number", "message": "data.number: must be string" },
{
"path": "data.customer.name",
"message": "data.customer.name: is required"
}
]
}
  • Without validate_data nothing is checked, whatever the schema says.
  • It needs a stored schema: a version that has none is answered with 400 invalid_request, and so is an html or url render.
  • format (email, date) is not checked; type, required, enum, pattern and the other keywords are.
  • In a batch, every item that sends validate_data is checked, and nothing is queued unless all of them pass.
  • In the SDKs it is validate_data: true (TypeScript) and validate_data=True (Python). To check data without rendering, use POST /templates/{id}/validate.