I would not treat the Typst function itself as the schema. Instead, separate the system into three contracts:
- Anytype schema — how information is stored.
- Render model schema — a normalized, versioned TypeScript/JSON representation.
- Typst template — how that render model is presented.
This avoids tightly coupling your Anytype property names, linked-object structure, and Typst layout.
Anytype object graph
│
▼
Anytype extractor / resolver
│
▼
Validated render model (JSON)
│
├── metadata → data.json
├── body → body.typ
└── files → assets/
│
▼
Typst template selected by renderer key
│
▼
PDF / SVG
Anytype’s current API can retrieve an object with its Markdown body, type and properties. Linked-object properties contain object IDs that your resolver can hydrate separately. The API now prefers the exported Markdown body over its older block representation. (developers.anytype.io)
For example, your Anytype Meeting Note type could have:
Renderer Key: meeting-note
Renderer Version: 1
Schema Version: 1
Or simply derive the renderer from the Anytype type key:
meeting_note → meeting-note
company_profile → company-profile
decision_record → decision-record
Then create a TypeScript registry:
// renderer-registry.ts
import { meetingNoteRenderer } from "./renderers/meeting-note.js";
import { companyProfileRenderer } from "./renderers/company-profile.js";
export const rendererRegistry = {
meeting_note: meetingNoteRenderer,
company_profile: companyProfileRenderer,
} as const;
export type SupportedAnytypeType = keyof typeof rendererRegistry;The rendering code should normally live in a Git repository, not inside each Anytype type. That gives you:
- version control;
- automated testing;
- syntax checking;
- shared components;
- controlled deployment;
- reproducible old documents;
- protection from executing accidentally modified source code.
If you want the templates editable from Anytype, use a dedicated Document Renderer object type rather than placing Typst code directly on every target type:
Document Renderer
├── Key
├── Version
├── Supported Type
├── Typst Source
├── Schema Version
├── Parent Renderer
└── Enabled
Your application would retrieve and cache that renderer object by key.
For a meeting note:
import { z } from "zod";
export const meetingNoteRenderModelSchema = z.object({
schemaVersion: z.literal(1),
source: z.object({
objectId: z.string(),
spaceId: z.string(),
lastModified: z.string().optional(),
}),
title: z.string(),
date: z.string().optional(),
location: z.string().optional(),
participants: z.array(
z.object({
id: z.string(),
name: z.string(),
role: z.string().optional(),
organization: z.string().optional(),
}),
),
decisions: z.array(
z.object({
title: z.string(),
description: z.string().optional(),
status: z.string().optional(),
}),
),
actionItems: z.array(
z.object({
title: z.string(),
assignee: z.string().optional(),
dueDate: z.string().optional(),
status: z.string().optional(),
}),
),
bodySource: z.string(),
});
export type MeetingNoteRenderModel = z.infer<
typeof meetingNoteRenderModelSchema
>;The Typst template only needs to understand this stable render model. It should not know that, for example:
- Anytype calls a property
meetingParticipants; - organizations are linked through person objects;
- action items are backlinks;
- dates arrive as ISO timestamps;
- some properties are absent.
That translation belongs in TypeScript.
// renderers/meeting-note.ts
import {
meetingNoteRenderModelSchema,
type MeetingNoteRenderModel,
} from "../schemas/meeting-note.js";
import type {
AnytypeObject,
AnytypeResolver,
RendererDefinition,
} from "../types.js";
async function buildMeetingNoteModel(
object: AnytypeObject,
resolver: AnytypeResolver,
): Promise<MeetingNoteRenderModel> {
const participantIds = resolver.objectIds(
object,
"meeting_participants",
);
const actionItemIds = resolver.objectIds(
object,
"action_items",
);
const decisionIds = resolver.objectIds(
object,
"decisions",
);
const [participants, actionItems, decisions] = await Promise.all([
resolver.getMany(participantIds),
resolver.getMany(actionItemIds),
resolver.getMany(decisionIds),
]);
const model = {
schemaVersion: 1 as const,
source: {
objectId: object.id,
spaceId: object.space_id,
lastModified: resolver.date(object, "last_modified_date"),
},
title: object.name,
date: resolver.date(object, "meeting_date"),
location: resolver.text(object, "location"),
participants: participants.map((participant) => ({
id: participant.id,
name: participant.name,
role: resolver.text(participant, "role"),
organization: resolver.linkedObjectName(
participant,
"organization",
),
})),
decisions: decisions.map((decision) => ({
title: decision.name,
description: resolver.text(decision, "description"),
status: resolver.select(decision, "status"),
})),
actionItems: actionItems.map((item) => ({
title: item.name,
assignee: resolver.linkedObjectName(item, "assignee"),
dueDate: resolver.date(item, "due_date"),
status: resolver.select(item, "status"),
})),
bodySource: object.markdown ?? "",
};
return meetingNoteRenderModelSchema.parse(model);
}
export const meetingNoteRenderer: RendererDefinition<
MeetingNoteRenderModel
> = {
templatePath: "templates/meeting-note.typ",
buildModel: buildMeetingNoteModel,
};Your resolver should include:
- object caching;
- cycle detection;
- maximum graph depth;
- missing-property handling;
- concurrent request limits;
- deterministic ordering;
- diagnostics for unresolved links.
Caching is important because current Anytype API documentation specifies sustained rate limiting with a burst allowance. (developers.anytype.io)
Typst’s sys.inputs receives CLI values as strings. It is suitable for small switches such as language, output mode or theme, but not for a complete nested document model. (Typst)
Use:
typst compile \
--input language=en \
--input variant=external \
main.typ \
output.pdfBut provide the actual document through data.json.
Typst can read raw JSON files or JSON bytes and converts objects and arrays into Typst dictionaries and arrays. (Typst)
Generated entry point:
#import "templates/meeting-note.typ": render
#let data = json("data.json")
#let body = include "body.typ"
#render(data, body: body)This eliminates the “map every function argument to a CLI argument” problem.
The Anytype body is Markdown, while the Typst template expects Typst content. I would not interpolate the Markdown directly into Typst source.
Instead:
Anytype Markdown
↓
Markdown AST
↓
escaped Typst markup
↓
body.typ
For example:
## Commercial discussion
The customer requested **three prototypes**.
- Prototype delivery in September
- Test report in OctoberCould become:
== Commercial discussion
The customer requested *three prototypes*.
- Prototype delivery in September
- Test report in OctoberThe converter must escape user text so that ordinary Anytype content cannot introduce Typst code.
A more robust long-term alternative is a neutral content AST:
[
{
"type": "heading",
"level": 2,
"children": [{"type": "text", "value": "Commercial discussion"}]
},
{
"type": "paragraph",
"children": [
{"type": "text", "value": "The customer requested "},
{"type": "strong", "value": "three prototypes"},
{"type": "text", "value": "."}
]
}
]But generating body.typ from a Markdown AST is simpler for the first implementation.
// templates/meeting-note.typ
#import "components/document.typ": document-header
#import "components/people.typ": participant-table
#import "components/actions.typ": action-table
#import "components/decisions.typ": decision-list
#let optional-field(data, key) = {
data.at(key, default: none)
}
#let render(data, body: none) = {
set document(
title: data.title,
author: "Business Development",
)
set page(
paper: "a4",
margin: (
top: 20mm,
bottom: 20mm,
left: 22mm,
right: 22mm,
),
)
document-header(
title: data.title,
date: optional-field(data, "date"),
location: optional-field(data, "location"),
)
if data.participants.len() > 0 {
heading(level: 2)[Participants]
participant-table(data.participants)
}
if body != none {
heading(level: 2)[Meeting record]
body
}
if data.decisions.len() > 0 {
heading(level: 2)[Decisions]
decision-list(data.decisions)
}
if data.actionItems.len() > 0 {
heading(level: 2)[Action items]
action-table(data.actionItems)
}
}The function is therefore the presentation contract, while the Zod schema is the actual machine-verifiable data contract.
// render-object.ts
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp } from "node:fs/promises";
import { rendererRegistry } from "./renderer-registry.js";
import { AnytypeClient } from "./anytype-client.js";
import { AnytypeObjectResolver } from "./anytype-resolver.js";
import { markdownToTypst } from "./markdown-to-typst.js";
import { compileWithTypstCli } from "./typst-cli.js";
export async function renderObject(
spaceId: string,
objectId: string,
outputPath: string,
): Promise<void> {
const client = new AnytypeClient({
baseUrl: process.env.ANYTYPE_API_URL!,
apiKey: process.env.ANYTYPE_API_KEY!,
});
const object = await client.getObject(spaceId, objectId);
const typeKey = object.type?.key;
if (!typeKey) {
throw new Error(`Object ${objectId} has no Anytype type key`);
}
const definition =
rendererRegistry[typeKey as keyof typeof rendererRegistry];
if (!definition) {
throw new Error(`No renderer registered for type: ${typeKey}`);
}
const resolver = new AnytypeObjectResolver(client, spaceId);
const model = await definition.buildModel(object, resolver);
const workDir = await mkdtemp(
join(tmpdir(), "anytype-typst-"),
);
await mkdir(join(workDir, "templates"), {
recursive: true,
});
await writeFile(
join(workDir, "data.json"),
JSON.stringify(model, null, 2),
"utf8",
);
await writeFile(
join(workDir, "body.typ"),
markdownToTypst(model.bodySource),
"utf8",
);
await copyRendererProjectFiles(
definition.templatePath,
workDir,
);
await writeFile(
join(workDir, "main.typ"),
`
#import "${definition.templatePath}": render
#let data = json("data.json")
#let body = include "body.typ"
#render(data, body: body)
`,
"utf8",
);
await compileWithTypstCli({
root: workDir,
entrypoint: join(workDir, "main.typ"),
outputPath,
});
}import { spawn } from "node:child_process";
export function compileWithTypstCli(options: {
root: string;
entrypoint: string;
outputPath: string;
}): Promise<void> {
return new Promise((resolve, reject) => {
const process = spawn(
"typst",
[
"compile",
"--root",
options.root,
options.entrypoint,
options.outputPath,
],
{
stdio: ["ignore", "pipe", "pipe"],
},
);
let stderr = "";
process.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
process.on("error", reject);
process.on("close", (exitCode) => {
if (exitCode === 0) {
resolve();
} else {
reject(
new Error(
`Typst exited with code ${exitCode}\n${stderr}`,
),
);
}
});
});
}The official Typst compiler is designed to compile Typst projects into PDF and other outputs and supports project roots, files, packages, fonts and assets naturally. (GitHub)
typst.ts is a separate JavaScript/WASM project that provides browser and Node-oriented compiler and renderer wrappers. It supports source compilation and PDF/SVG output, including virtual files through its shadow filesystem. (GitHub)
The integration would conceptually look like:
import { $typst } from
"@myriaddreamin/typst.ts/dist/esm/contrib/snippet.mjs";
const encoder = new TextEncoder();
await $typst.mapShadow(
"/data.json",
encoder.encode(JSON.stringify(model)),
);
await $typst.mapShadow(
"/body.typ",
encoder.encode(bodyTypst),
);
await $typst.mapShadow(
"/templates/meeting-note.typ",
encoder.encode(templateSource),
);
const mainContent = `
#import "/templates/meeting-note.typ": render
#let data = json("/data.json")
#let body = include "/body.typ"
#render(data, body: body)
`;
const pdfBytes = await $typst.pdf({ mainContent });The exact initialization and virtual-project API should be isolated behind your own interface because typst.ts is not the official Typst compiler API and its compiler/renderer initialization has changed between releases.
export interface TypstCompiler {
compilePdf(project: TypstProject): Promise<Uint8Array>;
}Then implement:
NativeTypstCompiler
TypstTsCompiler
without changing the rest of the application.
I recommend a hybrid renderer architecture:
Git repository
├── shared Typst design system
├── per-type Typst render functions
├── TypeScript adapters
├── Zod render schemas
└── Markdown converter
Anytype
├── source documents
├── structured properties
├── linked people, companies, tasks and decisions
└── renderer key/version
Use:
- native Typst CLI for authoritative PDF generation;
- typst.ts only where an embedded or browser-based preview is valuable;
- one JSON render model, not dozens of CLI arguments;
- one adapter per meaningful document type;
- shared Typst components for most formatting;
- a thin per-type Typst function, rather than completely independent templates;
- renderer code in Git, with an optional Anytype-based renderer registry only if editing templates inside Anytype is a firm requirement.
The first vertical slice should support only Meeting Note → normalized model → body.typ → meeting-note.typ → PDF. Once that works, adding another document type becomes primarily an adapter and a thin Typst render function.