Structured data · Core reference
JSON data.
JSON values are ordinary Nift expression values. Declare them with :=, use @:=(name){...} for a multiline value, load expression source with inject(), and compose validation with validate().
The current language keeps JSON composable with declarations, injection and validation rather than requiring a separate JSON-binding directive.
Declare JSON inline
$[site := {"name":"Example","navigation":[{"title":"Home","url":"/"}]}]
@:=(config){
{
"title": "Example",
"items": [1, 2, 3]
}
} Objects, arrays, strings, numbers, booleans and null are expression values. The binding type is inferred when declared and remains stable. Numbers that arrive already parsed from a JSON document keep their established representation; Nift source literals are typed by their lexical form, so 0.0 is a floating-point literal while 0 is an integer (see Declarations & assignments).
Access values
<h1>$[site.name]</h1>
<a href="$[site.navigation[0].url]">$[site.navigation[0].title]</a> Objects use .member; arrays use zero-based [index]. Scalar values render directly.
Expression-valued object literals
Object literal values are Nift expressions, exactly like array literal elements. Quoted string values are literal strings; every other value is evaluated left-to-right, once, and keeps its runtime type. Pure JSON objects (including big integers and scientific notation) are unchanged, and keys remain double-quoted strings.
$[x := 5]
$[obj := {"value": x, "next": x + 1, "enabled": true}]
$[label := "Nift"]
$[meta := {"name": label, "length": label.length()}]
$[summary := {"count": posts.size(), "first": posts.first()?.title ?? null}] Enums compose naturally and remain symbolic in rendering while serializing as their backing integer:
@enum(Status){ Draft, Published }
$[post := {"title": "Hello", "status": Status.Published}]
$[post.status] @// Published
$[post.status.to_int()] @// 1
$[post.stringify()] @// {"title":"Hello","status":1} Duplicate object keys, missing values and malformed members remain errors with useful diagnostics.
Working with JSON collections
JSON arrays and objects participate directly in Nift's collection operations, so structured data can be queried and reshaped without a separate query language. Bind the data once, then compose the read-only methods documented on the Collection operations page.
$[authors_by_id := data.authors.index_by(a => a.id)]
$[public_config := data.config.omit(["internal", "draft"])]
$[layered := data.defaults.merge_deep(data.overrides)]
$[round_trip := data.config.entries().from_entries()] $[posts := [
{"title":"One","draft":false,"year":2024,"tags":["nift","docs"]},
{"title":"Two","draft":true,"year":2025,"tags":["nift"]},
{"title":"Three","draft":false,"year":2025,"tags":["nift","markup"]}
]]
$[published := posts.filter(p => !p.draft)]
$[newest := published.sort_by(p => p.year)]
$[titles := published.map(p => p.title)]
$[years := posts.map(p => p.year).unique()]
$[all_tags := posts.map(p => p.tags).flatten().unique()]
$[recent := posts.count(p => p.year == 2025)]
$[by_year := posts.group_by(p => p.year)]
$[first := published.first()] Aggregation and checks compose naturally too:
$[total := orders.map(o => o.total).sum()]
$[latest := posts.map(p => p.year).max()]
$[earliest := posts.map(p => p.year).min()]
$[has_drafts := posts.any(p => p.draft)]
$[all_recent := posts.all(p => p.year >= 2024)]
$[csv := posts.map(p => p.title).join(", ")]
$[has_markup := posts.map(p => p.tags).flatten().contains("markup")] Objects expose keys(), values(), entries(), has(key), get(key[, default]), size(), empty() and non-mutating merge(other), so a configuration object can be inspected and combined without reaching for a separate JSON toolchain:
$[names := config.keys()]
$[port := config.get("port", 8080)]
$[full := defaults.merge(overrides)] Chains compose directly across calls, members and indices: first := published.first() and published.first().title are equivalent, so a filtered collection can be queried inline.
Computed (dynamic) object access
Bracket access is Nift's computed-access mechanism: the key between [ and ] may be a quoted string, a string binding, or any expression that resolves to a string. This covers dynamic keys without a separate lookup() primitive.
$[key := "title"]
$[config[key]]
$[config[page.name]] $[nested := data[outer][key].field]
$[row := records[index].value] A missing key is an error through obj["missing"]; use has(key) to test membership and get(key[, default]) to read with a fallback.
Null, missing keys and membership
null is Nift's single first-class absence value: there is no separate undefined. Accessing a missing object member errors, and get("missing") returns null (or its default). A key stored with an explicit null value remains distinguishable from a missing key via has(key).
$[config.has("port")]
@// false when absent, true when present even if null
$[config.get("port", 8080)]
@// fallback when absent
$[config["port"]]
@// errors when absent Load expression source from a file
$[site := inject("data/site.nift")]
$[products := inject("data/products.nift")] inject(path) parses the file contents as though that expression source appeared at the call site. It gets a child scope, can see bindings that existed before the call, and automatically participates in dependency tracking. The filename extension does not determine the value type.
Validate while declaring
$[site := validate(inject("schemas/site.schema.json"), inject("data/site.nift"))]
$[product := validate(inject("schemas/product.schema.json"), {
"name": "Markup++ in Nift",
"price": 19.95
})] For larger inline documents, the multiline declaration form keeps the JSON readable while still validating it as part of the declaration:
@:=(product){
validate(inject("schemas/product.schema.json"), {
"name": "Markup++ in Nift",
"price": 19.95,
"published": true,
"tags": [
"nift",
"markup"
]
})
} validate(schema, value) returns the original value unchanged when validation succeeds and fails the build otherwise. The value can be inline JSON like this; inject() is only needed when the expression comes from another file.
JSON Schema validation documents the supported schema subset, local $ref, errors and incremental behaviour.
Injected files and schemas are dependencies
Nift records injected source and schema files in the dependency graph. Changes therefore invalidate affected outputs in modified, hash and hybrid modes.
Scope and failures
Bindings follow Nift's lexical scope rules. Malformed values, missing or escaping paths, schema failures, missing members and invalid indices produce source-located build errors.
Frontend collection algebra
JSON arrays use the same non-mutating frontend operations as other Nift arrays, including partition, unique_by, min_by/max_by, count_by, take/drop/chunk, group_by_each, and stable compound sort_by. See Collection Operations for the complete reference.
$[published := data.posts.partition(p => p.published).matched]
$[by_tag := published.group_by_each(p => p.tags)]
$[cards := published.sort_by(p => p.featured, "desc", p => p.date, "desc").take(6)]