JSON Schema → Typst dict coercer.
Validate a JSON document against a JSON Schema (draft 7 subset) and
return a normalised Typst dict ready for downstream rendering. Ships with
the JSON Resume schema and
convenience entry points as the canonical bundled example.
"gairm" is Irish for vocation. The package was originally a JSON Resume loader.
Contents
Highlights
- Strict validation with path-qualified error reports and “did you mean …?” hints.
- One-call
parsethat validates and coerces in a single step, or split viavalidateandcoerce. - Two flavours of the canonical JSON Resume schema: a faithful 1:1 derivation and a renderer-friendly strict variant.
- Bring your own JSON-Schema-shaped document via the
schema:keyword — the engine is JSON-Schema-driven, not CV-specific. - Functional, lens-based editing for extension schemas without re-spelling the canonical shape.
- A JSON Schema (draft 7 subset) → Typst schema translator for callers with an existing
.jsonschema.
Requirements
- Typst
0.15.0or later.
Quick start
#import "@preview/gairm-import:0.9.0": parse
#let resume = parse(path("resume.json"))
// hand `resume` to any compatible Typst CV template
A minimal resume.json:
{
"basics": {
"name": "Seán Ó Murchú",
"label": "Senior Software Engineer",
"email": "sean@example.com",
"summary": "Backend engineer with eight years of experience."
},
"work": [
{
"name": "Acme Corp",
"position": "Senior Software Engineer",
"startDate": "2022-01",
"highlights": ["Led the event-sourcing platform migration."]
}
]
}
The canonical schema covers thirteen sections: basics, work, volunteer,
education, awards, certificates, publications, skills, languages,
interests, references, projects, meta. The $schema top-level
metadata field is also accepted. See
jsonresume.org/schema for every field.
Bring your own schema
gairm-import is JSON-Schema-driven, not CV-specific. Two ways to use a non-CV shape.
Translate an existing .json schema with schema-from-json-schema
(JSON Schema draft 7 subset — supported keywords listed under
Starting from a JSON Schema document):
#import "@preview/gairm-import:0.9.0": parse, schema-from-json-schema
#let book-schema = schema-from-json-schema(json("book-schema.json"))
#let book = parse(path("book.json"), schema: book-schema)
Or build the schema directly in Typst with the public combinators:
#import "@preview/gairm-import:0.9.0": (
parse, object, array-of, str-type, number-type,
)
#let book-schema = object((
title: str-type,
author: str-type,
year: number-type,
tags: array-of(str-type),
))
#let book = parse(path("book.json"), schema: book-schema)
Both go through the same validate / coerce / error-reporting
machinery — the JSON Resume schemas are just the bundled default. See
Schemas and composition for the full
schema-building API (lenses for targeted edits, the supported /
out-of-scope JSON Schema keywords, and more).
API at a glance
The five names a first-time reader will meet. Lens, introspection, and schema-building helpers are introduced later in Schemas and composition.
| Export | Purpose |
|---|---|
parse(data, schema: ...) |
Validate and coerce in one call; aborts compilation with a combined report on errors. |
validate(data, schema: ...) |
Return a list of (path, message) records — empty list means valid. |
coerce(data, schema: ...) |
Coerce a (validated) document into the typed model. |
resume-schema |
Default schema — faithful 1:1 derivation of the canonical JSON Resume document. |
resume-schema-strict |
Renderer-friendly overlay — free-text fields typed as Typst content, iso8601 $ref fields validated as dates. |
schema-from-json-schema(doc) |
Translate an existing JSON Schema (draft 7 subset) document into a Typst schema. Pair with parse(..., schema: ...). |
object, array-of, kind primitives |
Build a Typst schema directly. See Schemas and composition. |
Usage
Loading the document
parse is the one-call entry point. The recommended form is
parse(path("resume.json")) — the
path value resolves
against your own .typ (not the @preview cache), so you can use the
natural relative path:
#import "@preview/gairm-import:0.9.0": parse
#let resume = parse(path("resume.json"))
A parsed dict, a json("…") wrap, or a Typst-root-relative "/…" string
are also accepted — useful on older callers or when you’ve already loaded
the document yourself:
// json() resolves the path against your .typ; parse takes the dict.
#let resume = parse(json("resume.json"))
// Typst-root-relative path string, resolved by parse itself.
#let resume = parse("/resume.json")
The returned model
The returned dict is a 1:1 mirror of the canonical schema — every kind comes from the upstream JSON Schema document. Format-annotated fields are gated by a regex (see Format validation); everything else passes through as JSON-native types. For example:
resume.basics.name str
resume.basics.summary str
resume.basics.email str (gated as email)
resume.work.at(0).position str
resume.work.at(0).highlights array of str
resume.skills.at(0).keywords array of str
For renderer-friendly opinions (free-text fields wrapped as Typst content,
iso8601 $ref fields validated as dates), import resume-schema-strict
instead and pass it via the schema: keyword — see
Two schemas.
Rendering with a template
Pass the model into any compatible renderer — e.g. altacv:
#import "@preview/altacv:1.1.1": alta
#import "@preview/gairm-import:0.9.0": parse
#alta(parse(path("resume.json")))
If the renderer expects fields outside the canonical JSON Resume shape, build
an extension schema with the public combinators and pass it as schema: —
see Building an extension schema.
Errors
validate returns a list of (path, message) records — empty list means
the input is valid. parse validates first and aborts compilation with a
combined report on the first invocation that finds issues, so every problem
in the document surfaces in one error:
error: assertion failed: gairm-import: found 3 problems in the input:
- basics.email: expected string, got integer.
- work[0].positon: unknown key "positon". Did you mean "position"?
- meta.foo: unknown key "foo". Valid keys: canonical, version, lastModified.
When a typo is within edit distance 2 of a valid key, the message surfaces
a short “Did you mean …?” hint; otherwise it falls back to the full
valid-keys list shown for meta.foo.
Handling validation errors yourself
Each error is a record
(path: ("basics", "email"), message: "expected string, got integer.").
To present errors yourself instead of letting parse abort compilation,
run the two steps separately:
#import "@preview/gairm-import:0.9.0": validate, coerce
#let raw = json("resume.json")
#let errors = validate(raw)
#if errors.len() > 0 {
[Resume has #errors.len() issue(s).]
} else {
let model = coerce(raw)
// render model …
}
Null handling
JSON null is treated as if the key were absent — no validation error,
dropped from the coerced model. Null elements inside arrays are dropped the
same way. This matches the convention used by most JSON Resume emitters,
where "summary": null is semantically equivalent to omitting the key.
Unknown keys are still flagged even when their value is null, so typos do
not slip through silently.
Root null is rejected: if the entire input document is null, validate,
coerce, and parse panic with
gairm-import: input must be a dict, got null. The null-as-absent policy
applies to leaf positions inside a document, not to the document itself.
Schemas and composition
Two schemas
The package exports two values of the canonical schema:
resume-schema— a faithful 1:1 translation of the vendored upstream JSON Schema document. Every kind comes from the source; nothing is rewritten. This is the default when you callparse(data)/validate(data)/coerce(data).resume-schema-strict— adds three layered opinions on top via the lens API:- free-text fields (
basics.summary,work[].summary,work[].highlights[], etc.) are typed as Typstcontentso they splice directly into markup - iso8601
$reffields (startDate,endDate, …) are validated as ISO-8601 dates (the upstream document doesn’t carry aformatannotation on them, just a regex inside a definition) - upstream’s
additionalProperties: truemarkers — declared on every section’s items, so the faithful default lets undeclared extras pass through — are stripped recursively, restoring the “unknown keys are rejected” promise (typed extras viaadditionalProperties: <schema>are kept)
- free-text fields (
Pass schema: resume-schema-strict to opt in:
#import "@preview/gairm-import:0.9.0": parse, resume-schema-strict
#let resume = parse(path("resume.json"), schema: resume-schema-strict)
The faithful default is the source-of-truth view; the strict variant is a
renderer-ergonomics overlay. If you want a different mix, build your own by
lensing over resume-schema — see
Targeted edits with lenses.
Format validation
Fields the canonical schema annotates with format: "uri", format: "email",
or format: "date" are gated by a regex during validate / parse. The
patterns are deliberately permissive — they reject obvious malformations
without claiming full RFC compliance — and each emits a path-qualified
message with a canonical example:
basics.email: expected an email (e.g. "name@example.com").
basics.url: expected a URI (e.g. "https://example.com").
certificates[0].date: expected an ISO-8601 date (e.g. "2024-01-15").
format: "date-time" maps to the separate datetime-string kind:
date-string accepts YYYY / YYYY-MM / YYYY-MM-DD, while
datetime-string requires the full YYYY-MM-DDTHH:MM:SS shape (optional
fractional part and Z / ±HH:MM offset). The canonical document carries
no date-time annotations, so that kind only fires via
schema-from-json-schema or a lens override.
Most date fields in JSON Resume (work[].startDate, awards[].date,
meta.lastModified, …) use $ref: "#/definitions/iso8601" rather than
format: "date". The translator can’t pick formats up from a $ref alone,
so those fields stay as plain str in resume-schema. Switch to
resume-schema-strict to validate them as dates, or build your own override
list with lens-put(lens(path), schema, date-string).
Coercion is pass-through: format-checked values flow through to the model as
plain strings, so renderers receive model.basics.email == "name@example.com"
unchanged.
For ad-hoc constraints outside the four built-in formats, build a
pattern-string(re, expected: …) and target it via a lens or splice it
into an extension schema. JSON Schema’s pattern keyword on a plain string
maps to this kind too — see
Starting from a JSON Schema document
for the precedence rule when both format and pattern are present:
#import "@preview/gairm-import:0.9.0": (
resume-schema, lens, lens-put, pattern-string,
)
// Gate basics.location.countryCode as an ISO 3166-1 alpha-2 code.
#let with-country-code = lens-put(
lens(("basics", "location", "countryCode")),
resume-schema,
pattern-string(
"^[A-Z]{2}$",
expected: "an ISO 3166-1 alpha-2 code (e.g. \"US\")",
),
)
Typst’s regex match finds a match anywhere in the string, so anchor the
pattern yourself if you need a full-string match — ^…$ is the common case.
Building an extension schema
parse is strict against declared fields in the canonical schema: keys that
aren’t declared and aren’t covered by an upstream additionalProperties
clause are rejected. (Upstream JSON Resume sets additionalProperties: true
on every section’s items, so extras in those positions pass through —
Two schemas covers the strict variant that strips them.)
Renderers that expect their own top-level fields in the resume document
(e.g. alta-typst’s focusAreas) can build a JSON-Resume+ schema with the
public combinators and pass it to parse / validate / coerce via the
schema: keyword:
#import "@preview/gairm-import:0.9.0": (
resume-schema, parse, object, array-of, content-type,
)
// Splice the canonical shape and add a renderer-specific field.
#let altacv-schema = object((
..resume-schema.shape,
focusAreas: array-of(content-type),
))
#let model = parse(path("resume.json"), schema: altacv-schema)
resume-schema.shape is a plain dict, so ..resume-schema.shape is the
only operator you need to extend it. Per-section combinators (work-item,
volunteer-item, …) are intentionally not exposed yet — splice the
canonical top-level fields whole and add your own siblings.
Targeted edits with lenses
Splicing ..resume-schema.shape works for top-level additions but is
awkward when the field you want to touch is three or four levels deep
(work items’ highlights element schema, basics.email, …). For those
cases, lenses target a path inside the schema and return a new schema with
the targeted node replaced or transformed:
#import "@preview/gairm-import:0.9.0": (
resume-schema, lens, lens-put, lens-over, add-field,
set-required, unset-required,
str-type, content-type, number-type, object,
)
// Widen basics.summary from content (rich) to str (plain) — useful if
// you want the summary rendered as plain text instead of formatted:
#let plain-summary = lens-put(
lens(("basics", "summary")), resume-schema, str-type,
)
// Add a numeric `rating` to every language entry — touches
// resume-schema.shape.languages.elem.shape without re-spelling the wrapper:
#let with-rating = add-field(
resume-schema, lens(("languages", "items")), "rating", number-type,
)
// Transform an existing node with a function:
#let with-extra-meta = lens-over(
lens(("meta",)),
resume-schema,
meta => object((..meta.shape, source: str-type)),
)
// Make basics.name and basics.email required for your template
// (canonical schema declares no required keys):
#let strict-basics = set-required(
resume-schema, lens(("basics",)), ("name", "email"),
)
// Relax email back without re-spelling the rest of the required list:
#let mixed-basics = unset-required(
strict-basics, lens(("basics",)), ("email",),
)
Path segments match JSON Schema keyword names: object keys as strings, the
literal "items" to enter an array’s element schema, and the literal
"additionalProperties" to enter an object’s additional (the
additionalProperties schema; only valid when additional is a schema dict,
not true). Composition (lens-then(a, b)) concatenates paths, so
lens-then(lens(("work",)), lens(("items", "highlights"))) is the same
lens as lens(("work", "items", "highlights")). The empty path lens(())
is the identity lens.
| Function | Shape | Behaviour |
|---|---|---|
lens(path) |
path → lens |
Construct a lens from a path tuple |
lens-get(l, schema) |
lens, schema → sub-schema |
Read the targeted node |
lens-put(l, schema, value) |
lens, schema, sub → schema |
Replace the targeted node |
lens-over(l, schema, fn) |
lens, schema, (sub → sub) → schema |
Apply a function to the targeted node |
lens-then(a, b) |
lens, lens → lens |
Compose two lenses (path concatenation) |
add-field(schema, parent, key, sub) |
… → schema | Add a key to the object at parent |
remove-field(schema, parent, key) |
… → schema | Remove a key from the object at parent |
set-required(schema, parent, keys) |
… → schema | Replace the object’s required-keys list at parent |
unset-required(schema, parent, keys) |
… → schema | Drop specific entries from the object’s required-keys list at parent |
Operations are functional — each helper above returns a NEW schema and
leaves the input untouched, so you can chain edits without disturbing the
canonical one. (Operations are top-level functions rather than methods
because Typst parses lens.put(…) as a type-method lookup, not a closure
call.)
Inspecting a schema
When an extension schema misbehaves, describe-schema, paths-of-kind,
and kind-at answer the three usual questions — what does this thing
look like?, where do my date strings live?, what kind is at this
path? — without repr(schema) or hand-walking .shape:
#import "@preview/gairm-import:0.9.0": (
resume-schema-strict, describe-schema, paths-of-kind, kind-at,
)
// Tree view of every leaf, with array sections suffixed `[]`.
#describe-schema(resume-schema-strict)
// basics:
// email email-string
// name str
// summary content
// …
// work[]:
// highlights[] content
// startDate date-string
// …
// Every lens-compatible path whose terminal kind matches.
#paths-of-kind(resume-schema-strict, "date-string")
// → (("work", "items", "startDate"), …)
// Kind at a single path — thin wrapper over lens-get.
#kind-at(resume-schema-strict, ("basics", "summary")) // "content"
Array segments in returned path tuples use "items" so they plug straight
into lens(path); the [] suffix in describe-schema’s output is
human-friendly visual only. Keys sort alphabetically so diffs across schema
versions stay stable.
The real leverage comes from folding paths-of-kind together with
lens-put to bulk-edit every field of a kind in one pass — the list of
paths is derived from the schema, so new fields an upstream JSON Resume
bump introduces are covered automatically:
#import "@preview/gairm-import:0.9.0": (
resume-schema, paths-of-kind, lens, lens-put, pattern-string,
)
// Tighten every uri-string field to a corporate-domain pattern,
// without enumerating the paths by hand.
#let corporate-uri = pattern-string(
"^https://(corp|docs)\.example\.com/",
expected: "a corporate URL",
)
#let corporate-schema = paths-of-kind(resume-schema, "uri-string").fold(
resume-schema,
(schema, path) => lens-put(lens(path), schema, corporate-uri),
)
JSON Pointer interop
Lens paths and validator error paths are (seg, seg, ...) tuples — natural
in Typst but they don’t directly interoperate with external tooling that
speaks RFC 6901 JSON Pointer
(editor extensions for schema-aware completion, schema diff tools, JSON
Schema documentation generators, …). path-to-pointer / pointer-to-path
cross the boundary:
#import "@preview/gairm-import:0.9.0": path-to-pointer, pointer-to-path
#path-to-pointer(("basics", "email")) // "/basics/email"
#path-to-pointer(("work", 0, "highlights", 1)) // "/work/0/highlights/1"
#path-to-pointer(("a/b",)) // "/a~1b" — `/` escapes as `~1`
#path-to-pointer(("~tilde",)) // "/~0tilde" — `~` escapes as `~0`
#pointer-to-path("/work/0/highlights/1") // ("work", 0, "highlights", 1)
#pointer-to-path("") // () — whole document
#pointer-to-path("/") // ("",) — empty-string key at root
Two addressing schemes share the same encoder. Validator error paths
(mixed str / non-negative int) address into data — the output is a
real RFC 6901 pointer any JSON-Pointer-aware tool can dereference against
the document. Lens and introspect paths (str-only, with "items" and
"additionalProperties") address into the schema — the output names a
schema location the way JSON Schema tooling does in $ref
(e.g. #/properties/foo/items), not a data position.
Malformed input panics rather than passing through silently: non-str/int
or negative segments at encode, invalid ~ escapes at decode.
pointer → path → pointer round-trips losslessly; path → pointer → path
is lossless except for str segments that look like array indices
(("0",) decodes back as (0,)) — which validator and lens paths never
emit in practice.
Starting from a JSON Schema document
schema-from-json-schema(parsed-schema) translates a JSON Schema (draft 7
subset) into a Typst schema dict. Use it when you already have an
authoritative .json schema and don’t want to keep a parallel Typst copy
in sync:
#import "@preview/gairm-import:0.9.0": (
schema-from-json-schema, coerce, object, array-of, content-type,
)
#let canonical = schema-from-json-schema(path("resume-schema.json"))
#let altacv-schema = object((
..canonical.shape,
focusAreas: array-of(content-type),
))
#let model = coerce(json("resume.json"), schema: altacv-schema)
Supported keywords:
type:string/number/integer/array/object/boolean/null—integerrejects numbers with a non-zero fractional part (1.0passes, per draft-7 semantics); the matching Typst-side primitive isinteger-typeformat:uri→uri-string,email→email-string,date→date-string,date-time→datetime-stringpattern→pattern-string(on plain string schemas only; when bothformatandpatternare present on the same node,formatwins andpatternis dropped — compose two gates yourself via a lens if you need both)enum→enum-of,const→const-ofproperties,required,items- Internal
$ref(#/definitions/…/#/$defs/…) anyOf→any-of(at least one member matches),oneOf→one-of(exactly one member matches),not→not-of(value must not match) — the composition keyword must stand alone on its node (annotation-only siblings liketitle/descriptionexcepted); a siblingtype, constraint, or$refpanics rather than being silently ignoredallOf— merged at translate time; every member must be an object schema (shapes union,requiredunion; a duplicate key must carry an identical sub-schema;additionalPropertiesmust agree across all members, with an undeclared member counting as closed). Non-object composition (e.g. string + extra constraints) panicstype: [X, "null"]nullable unions (under the engine’s null-as-absent policy these translate to plainX)- String constraints:
minLength,maxLength - Number constraints:
minimum,maximum,exclusiveMinimum,exclusiveMaximum,multipleOf - Array constraints:
minItems,maxItems,uniqueItems additionalProperties: a schema,true, orfalse—falsematches the strict default;truepermits extras without validation; a schema validates every extra against it (also reachable via themap(value-schema)combinator)
Constraint keywords are baked onto the kind dict as kebab-case fields and validated inline.
Out of scope. Each keyword below panics with a clear “unsupported” message rather than silently dropping the constraint:
if/then/elsedependencies(and thedependentRequired/dependentSchemasvariants)- Constraint keywords (
minLength,minimum,minItems, …) combined withenum/const— membership already pins the exact values, so fold the constraint into the value list instead (typealongsideenum/constremains accepted as redundant) type: "object"with neitherpropertiesnoradditionalProperties(fully open)type: [...]unions with more than one non-null member- External
$ref - String formats other than the four listed above
allOfwith non-object members, and schema-bearing sibling keywords beside any composition keyword (move them into the members)
Contributing
See CONTRIBUTING.md. Releases are cut by
release-please from
conventional-commit titles on main.
License
MIT.