@archduck/gst-compose
v0.1.0
Published
Generic JSON/JS composition engine with spread semantics
Downloads
100
Maintainers
Readme
gst-compose
A JavaScript library for composing JSON data. Apply a patch of new data to existing data to replace it or merge with it. Pass in a dictionary for the patch to reference things by name.
gst stands for Grand Schema Things -- a play on "grand scheme of things." The gst family of libraries builds UIs from JSON configurations rather than code. gst-compose is the foundation: the composition engine that makes layered config possible.
Two functions, zero dependencies, ~230 lines.
npm install @archduck/gst-composeimport { apply, reduce } from '@archduck/gst-compose'Why
Config varies. A SaaS product has per-tenant settings. An app has dev/staging/prod environments. A UI has locale-specific strings. The usual options are branching logic (if tenant === 'acme') or deep merge (lodash.merge).
Branching grows with every new variant. Deep merge gives you no control -- it merges everything recursively, which isn't always what you want. Sometimes you need to replace an array, sometimes extend it. Sometimes replace a nested object, sometimes patch one field.
gst-compose puts the merge strategy in the data itself. A patch that uses "...": "^" keeps the base and overrides on top. A patch without it replaces entirely. This decision is made per-key, at any nesting depth, by the patch -- not by the code that processes it. The processing code is always just apply(base, patch).
base.json -- the default compensation package:
{
"salary": 50000,
"bonus": false,
"retirement": "none",
"insurance": "basic",
"dental": true,
"vision": true,
"pto": 10,
"sickDays": 5,
"remote": false,
"relocation": false,
"parking": true,
"laptop": "standard",
"phone": false,
"tuition": false,
"gym": false
}senior.json -- two changes:
{ "...": "^", "salary": 90000, "bonus": true }executive.json -- three changes:
{ "...": "^", "salary": 150000, "retirement": "401k-match", "insurance": "premium" }The JavaScript:
const config = apply(base, patch)One line. The patch file describes everything -- what to keep, what to change. You can see at a glance what each level overrides.
Spread for JSON
JavaScript merges objects with spread:
const defaultPrefs = { lang: "en", notifications: true, theme: "light" }
const userPrefs = { ...defaultPrefs, theme: "dark" }
// { lang: "en", notifications: true, theme: "dark" }JSON has no spread operator. gst-compose adds one. The "..." key means "spread from":
{ "...": "defaultPrefs", "theme": "dark" }When gst-compose processes this, it finds the object called "defaultPrefs", spreads its properties in, then applies "theme": "dark" on top. Same result as the JS spread.
apply
apply(base, patch, dictionary) -- combine two objects into one.
base-- the existing objectpatch-- the new object, applied on top ofbasedictionary-- a dictionary of objects thatpatchcan reference by key
patch replaces base:
apply(
{
salary: 50000,
bonus: false,
retirement: "none",
insurance: "basic",
pto: 10
},
{ salary: 90000 }
)
// !! { salary: 90000 }Only patch survives. To keep the rest of the record, patch spreads from base with "...": "^":
apply(
{
salary: 50000,
bonus: false,
retirement: "none",
insurance: "basic",
pto: 10
},
{ "...": "^", salary: 90000 }
)
// {
// salary: 90000,
// bonus: false,
// retirement: "none",
// insurance: "basic",
// pto: 10
// }patch can also spread from a key in dictionary:
apply(
{
salary: 50000,
bonus: false,
retirement: "none",
insurance: "basic",
pto: 10
},
{ "...": "senior", pto: 25 },
{
senior: {
salary: 90000,
bonus: true,
pto: 20
}
}
)
// { salary: 90000, bonus: true, pto: 25 }To spread from both base and a dictionary entry, use an array:
apply(
{
salary: 50000,
bonus: false,
retirement: "none",
insurance: "basic",
pto: 10
},
{ "...": ["^", "senior"], pto: 25 },
{
senior: {
salary: 90000,
bonus: true,
pto: 20
}
}
)
// {
// salary: 90000,
// bonus: true,
// retirement: "none",
// insurance: "basic",
// pto: 25
// }Spreads base first, then senior on top, then patch's own keys override both. retirement and insurance survived from base because senior doesn't touch them. pto is 25 because the patch overrode senior's 20.
Nesting
Spread corresponds to the same location in the base. A nested "^" spreads from the base's value at that same key:
apply(
{
salary: 50000,
benefits: {
insurance: "basic",
retirement: "none"
},
perks: ["parking"]
},
{
"...": "^",
salary: 90000,
benefits: {
"...": "^",
retirement: "401k-match"
},
perks: ["...", "gym", "lunch"]
}
)
// {
// salary: 90000,
// benefits: {
// insurance: "basic",
// retirement: "401k-match"
// },
// perks: ["parking", "gym", "lunch"]
// }The outer "^" keeps salary, benefits, and perks from the base. The inner "^" on benefits keeps insurance while overriding retirement. The "..." in perks splices in the base's ["parking"] and appends the new items.
Without "..." at each level, that level gets replaced entirely. Spread is explicit, not recursive.
If a nested spread doesn't correspond to anything in the base, it fails safely -- "^" spreads from an empty object, "..." splices in an empty array:
apply(
{
salary: 50000
},
{
"...": "^",
benefits: {
"...": "^",
retirement: "401k-match"
}
}
)
// {
// salary: 50000,
// benefits: {
// retirement: "401k-match"
// }
// }Arrays don't support dictionary lookups. "..." in an array is always a positional marker meaning "insert the base array here."
reduce
Apply multiple patches in sequence. Each patch decides independently whether to spread from the previous result or replace it:
reduce([
{
salary: 50000,
bonus: false,
pto: 10
},
{ "...": "^", salary: 90000 },
{ "...": "^", bonus: true },
{ "...": "^", pto: 25 }
])
// {
// salary: 90000,
// bonus: true,
// pto: 25
// }Each entry is applied on top of the accumulated result from left to right.
__source
Bring a subset of the dictionary into local scope so children don't need full paths:
const dictionary = {
packages: {
senior: {
salary: 90000,
bonus: true
},
executive: {
salary: 150000,
bonus: true,
retirement: "401k-match"
}
}
}
apply(
{},
{
__source: "packages",
cto: { "...": "executive", pto: 30 }
},
dictionary
)
// {
// cto: {
// salary: 150000,
// bonus: true,
// retirement: "401k-match",
// pto: 30
// }
// }Without __source, the child would need "...": "packages.executive". Dot notation reaches into nested dictionary paths.
Accepts a string, an array of strings, or null to block all resolution.
String shorthand
When a key and value are the same string, and that string exists in the dictionary, it resolves as a reference:
const dictionary = {
senior: {
salary: 90000,
bonus: true
},
executive: {
salary: 150000,
bonus: true,
retirement: "401k-match"
}
}
apply(
{},
{
__source: "roles",
senior: "senior",
executive: "executive"
},
{ roles: dictionary }
)
// {
// senior: { salary: 90000, bonus: true },
// executive: { salary: 150000, bonus: true, retirement: "401k-match" }
// }Equivalent to "senior": { "...": "senior" }.
Idempotency
apply consumes its directives ("...", __source) during resolution. The output is plain data with no directives left. Running it through apply again produces the same result:
const first = apply(
{},
{ "...": "senior", pto: 25 },
dictionary
)
const second = apply({}, first, dictionary)
// first and second are structurally identicalFunction values pass through by reference, not by copy.
API
apply(base, patch, dictionary?) -> result
Apply patch on top of base, resolving spread and source directives against dictionary.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| base | any | -- | The existing object |
| patch | any | -- | The new object to apply on top |
| dictionary | object | {} | Objects that patch can reference by key |
reduce(patches, initial?, dictionary?) -> result
Apply multiple patches in sequence, left to right.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| patches | array | -- | Patches to apply in order |
| initial | any | {} | Starting value |
| dictionary | object | {} | Objects that patches can reference by key |
loadFile(path) -> Promise
Load a JSON or JS file. JSON files are fetched and parsed; JS files are dynamically imported.
Special keys
| Key | In objects | In arrays |
|-----|-----------|-----------|
| "..." | Spread from base ("^"), dictionary ("name"), or both (["^", "name"]) | Spread previous array at this position |
| __source | Narrow dictionary for child lookups | N/A |
Both keys are consumed during resolution and never appear in the output.
Caveats
- No prototype pollution guards. If the dictionary comes from untrusted input, sanitize keys.
- No circular reference detection on objects. Circular dictionary strings are fine (path traversal only), but circular object references will overflow the stack.
- No max depth limit. Config objects rarely go deep enough for this to matter.
- Unresolved spread references are preserved in the output for multi-pass resolution.
- Functions pass through by reference, not cloned.
