How to make fields agree with each other
Random-per-field data produces nonsense: an email that has nothing to do with the name, a total that doesn't equal price plus tax, a city in the wrong country. The fix is to draw a value once, name it with a let, and derive everything else from that name. Because a let binds a name to a single value, referencing it again always yields the same value — coherence is a property of the let, not luck.
Steps
- In a composition's
letblock, draw each independent value once and give it a name. - Compute the dependent values from those names — either with a
{ "computed": "<PEL>" }letstep, or directly in theoutput. - Emit an
outputobject whose leaves reference the names. Every leaf is a PEL expression evaluated against the same bindings, so they always agree.
Example 1 — an email that matches the name
{
"name": "@demo/coherent",
"version": "0.1.0",
"generators": [
{ "name": "@demo/coherent:person",
"let": {
"first": { "type": "list", "source": "inline", "values": ["Ada","Grace","Alan","Linus"] },
"last": { "type": "list", "source": "inline", "values": ["Lovelace","Hopper","Turing","Torvalds"] }
},
"output": {
"name": "{{ concat(first, ' ', last) }}",
"email": "{{ concat(lowercase(first), '.', lowercase(last), '@example.com') }}"
} }
]
}phony generate --use @demo/coherent:person --package ./coherent --seed 42 -n 3[
{ "email": "grace.lovelace@example.com", "name": "Grace Lovelace" },
{ "email": "grace.hopper@example.com", "name": "Grace Hopper" },
{ "email": "linus.hopper@example.com", "name": "Linus Hopper" }
]first and last are each drawn once; both name and email read those same two values, so the email can never disagree with the name.
Note that + is arithmetic-only in PEL — build strings with concat(…), never first + ' ' + last.
Example 2 — a total that matches its price and tax
Compute derived numbers with computed let steps (evaluated in dependency order) so each builds on the last:
{ "name": "@demo/coherent:invoice_line",
"let": {
"price": { "type": "logic", "algorithm": "float_between", "params": { "min": 10, "max": 100 } },
"tax": { "computed": "round(price * 0.2, 2)" }
},
"output": {
"price": "{{ round(price, 2) }}",
"tax": "{{ tax }}",
"total": "{{ round(price + tax, 2) }}"
} }phony generate --use @demo/coherent:invoice_line --package ./coherent --seed 42 -n 3[
{ "price": 52.44, "tax": 10.49, "total": 62.93 },
{ "price": 97.27, "tax": 19.45, "total": 116.72 },
{ "price": 11.61, "tax": 2.32, "total": 13.93 }
]tax and total are both derived from the one price draw, so the arithmetic always closes.
Example 3 — a city/district/postal that never mismatch
When several fields must come from the same real-world record, don't draw them separately — draw one object and pick its properties apart. A list over an array of objects returns one whole record:
{ "name": "@demo/coherent:address",
"let": {
"loc": { "type": "list", "source": "inline", "values": [
{ "city": "İstanbul", "district": "Kadıköy", "postal": "34710" },
{ "city": "Ankara", "district": "Çankaya", "postal": "06420" },
{ "city": "İzmir", "district": "Konak", "postal": "35250" }
] }
},
"output": {
"city": "{{ loc.city }}",
"district": "{{ loc.district }}",
"postal": "{{ loc.postal }}"
} }phony generate --use @demo/coherent:address --package ./coherent --seed 42 -n 3[
{ "city": "İzmir", "district": "Konak", "postal": "35250" },
{ "city": "İzmir", "district": "Konak", "postal": "35250" },
{ "city": "İstanbul", "district": "Kadıköy", "postal": "34710" }
]You never get "Ankara, Konak, 34710" — the three fields are properties of a single picked record. This is the coherent-record pattern; the same shape works for a locale-resolved asset of objects (see Serve locale-specific data).
The rule of thumb
- Independent value? Draw it in a
let, name it. - Derived value? Compute it from names — with a
computedletor anoutputleaf. - Fields from the same record? Draw the whole record once (a
listof objects) and read its properties; don't draw the fields separately.
Pitfalls
- A reference resolves against the generator's own bindings only — its params and
letresults. There is no row, table, or sibling field to reach for; if you want two fields to agree, they must both derive from a sharedlet. - A whole-expression leaf keeps its type. A leaf that is exactly one
{{ … }}(like"{{ tax }}") yields the underlying number/array; a leaf with surrounding text renders to a string. - Unknown references fail at load time, naming the bad reference — so a typo like
{{ frist }}never reaches generation.