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.
Quick start
#import "@preview/gairm-import:0.8.1": parse
#let resume = parse(path("resume.json"))
// hand `resume` to any compatible Typst CV template
Bring your own JSON-Schema-shaped document and pass schema: to parse — the
engine doesn’t know or care that it’s a CV.
Install
#import "@preview/gairm-import:0.8.1": validate, coerce, parse
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.
Usage
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.8.1": 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 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.8.1": 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.
Handling validation errors yourself
Each error is a record
(path: ("basics", "email"), message: "expected string, got integer.").
A typical step-by-step is:
#import "@preview/gairm-import:0.8.1": 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 …
}
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.
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.
Advanced
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 two 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)
- free-text fields (
Pass schema: resume-schema-strict to opt in:
#import "@preview/gairm-import:0.8.1": 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" is supported via the datetime-string kind. The
canonical JSON Resume document doesn’t currently carry any date-time
annotations, so the kind only fires when a caller translates their own JSON
Schema with schema-from-json-schema, or lens-overrides a field. The two
date kinds are intentionally separate:
date-stringacceptsYYYY/YYYY-MM/YYYY-MM-DD.datetime-stringrequires the fullYYYY-MM-DDTHH:MM:SSshape with an optional fractional component and an optionalZor±HH:MMoffset.
Widening the date regex to also match datetime values would mislabel pure-date fields.
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.8.1": (
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 — see
the note further down on additionalProperties.)
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.8.1": (
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)
When to reach for which API:
| API | Behaviour |
|---|---|
parse(data) |
One call, aborts compilation with a combined report on validation issues. Defaults to the canonical schema; pass schema: … to use an extension. |
validate(data) / coerce(data) |
Return data instead of aborting, so you can present errors yourself (see the step-by-step above). Same schema: default. |
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.8.1": (
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 — every lens-put / lens-over / add-field /
remove-field / set-required / unset-required returns a NEW schema and
leaves the input untouched, so you can build an extension schema by chaining
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.8.1": (
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.8.1": (
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.8.1": 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 — pick the right one for your use case:
- Validator error paths (mixed
str/ non-negativeint) address into data. The output is a real RFC 6901 JSON Pointer that any JSON-Pointer-aware tool can dereference against the resume / data document. - Lens and introspect paths (
str-only, with"items"for array elements and"additionalProperties"for the additional schema) address into the schema. The output is a JSON-Pointer-shaped string that names a schema location — meaningful to JSON Schema tooling that uses JSON Pointer in$ref(e.g.#/properties/foo/items), but not a data pointer.
Encoding accepts str (object key) or int (non-negative — RFC 6901’s
array-index ABNF) segments; other types and negative ints panic. Decoding
parses tokens matching that ABNF (0 | [1-9][0-9]*) back to int;
everything else stays str. Malformed ~ escapes (bare ~, ~2,
~<other>) panic at decode rather than silently passing through.
Round-trip directions are asymmetric:
pointer → path → pointeris lossless for any well-formed pointer.path → pointer → pathis lossless except when astrsegment looks like an array index —("0",)decodes back as(0,). In practice the validator and lens code never emit numeric strings, so this isn’t a concern.
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.8.1": (
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/nullformat: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/…) type: [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 (every one panics with a clear “unsupported” message rather than silently dropping the constraint):
allOf/anyOf/oneOf/notif/then/elsedependencies(and thedependentRequired/dependentSchemasvariants)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
A note on the canonical resume-schema and additionalProperties: the
upstream JSON Resume document declares additionalProperties: true on every
section’s items, so the canonical schema accepts extras at runtime even
though the README’s headline framing is “strict”. Strict applies to declared
fields; additionalProperties: true from upstream is honoured. If you need
stricter behaviour, use resume-schema-strict — it recursively strips
additional: true from every nested object, restoring the “unknown keys are
rejected” promise (typed extras declared via additionalProperties: <schema>
are kept).
Contributing
See CONTRIBUTING.md. Releases are cut by
release-please from
conventional-commit titles on main.
License
MIT.