Universe

gairm-import on Typst Universe Latest GitHub release version of gairm-import GitHub Actions build workflow status on the gairm-import main branch MIT license badge linking to the gairm-import LICENSE file Number of GitHub stargazers for gairm-import

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 call parse(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 Typst content so they splice directly into markup
    • iso8601 $ref fields (startDate, endDate, …) are validated as ISO-8601 dates (the upstream document doesn’t carry a format annotation on them, just a regex inside a definition)

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-string accepts YYYY / YYYY-MM / YYYY-MM-DD.
  • datetime-string requires the full YYYY-MM-DDTHH:MM:SS shape with an optional fractional component and an optional Z or ±HH:MM offset.

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-negative int) 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 → pointer is lossless for any well-formed pointer.
  • path → pointer → path is lossless except when a str segment 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 / null
  • format: uriuri-string, emailemail-string, datedate-string, date-timedatetime-string
  • patternpattern-string (on plain string schemas only; when both format and pattern are present on the same node, format wins and pattern is dropped — compose two gates yourself via a lens if you need both)
  • enumenum-of, constconst-of
  • properties, required, items
  • Internal $ref (#/definitions/… / #/$defs/…)
  • type: [X, "null"] nullable unions (under the engine’s null-as-absent policy these translate to plain X)
  • String constraints: minLength, maxLength
  • Number constraints: minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
  • Array constraints: minItems, maxItems, uniqueItems
  • additionalProperties: a schema, true, or falsefalse matches the strict default; true permits extras without validation; a schema validates every extra against it (also reachable via the map(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 / not
  • if / then / else
  • dependencies (and the dependentRequired / dependentSchemas variants)
  • type: "object" with neither properties nor additionalProperties (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.