# Pipes Pipes are **named, unary functions** that you register per call and use as **path segments** in RHS values. When a segment names a pipe, the current value is passed through it: ```JavaScript const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }); assignFrom(timeElement, { dateTime: '?.', 'textContent =&': { join: ['?.parseDate?.longUtc', ' UTC'] }, }, { from: '2026-03-14T15:09:26.000Z', pipes: { parseDate: Date.parse, longUtc: fmt.format, }, }); // timeElement.textContent === 'March 14, 2026 at 3:09 PM UTC' ``` `'?.parseDate?.longUtc'` reads as: take the current value, pipe it through `parseDate`, then through `longUtc`. The name is inspired by the TC39 `|>` pipeline proposal. ## Why Pipes Paths can navigate into values and call their methods (`withMethods`), but a method takes at most **one string argument** (the next segment). That means a path can't: - convert a value into another type (string → `Date`); - call a function with an options object (`toLocaleString(locale, { … })`); - build conditional text from several fields. Pipes move that logic into JS, where functions and objects are natural, while the pattern only **names** them. Patterns stay JSON. Parameterized formatting is set up once in JS (as with the `Intl.DateTimeFormat` above) instead of being encoded in the path. ## Rules - **Unary.** A pipe is called as `fn(current)`. It never consumes the next segment as an argument, so there's no argument syntax in paths. - **Called with `this` undefined.** Pass functions that don't need a receiver: static functions (`Date.parse`, `Temporal.Instant.from`), bound functions, arrow functions, or `Intl` formatters' `format` (a bound getter). - **Pipes take precedence** over a property of the same name on the current value. They're registered per call, so a match is the author's intent. Pick names that don't collide with data keys you also need to read. - **Never called with nullish.** Path navigation stops at `null` / `undefined` before reaching the pipe. For placeholders, use a ` ?=` chain: `'textContent ?=': ['?.details', '??', 'No details provided.']`. - **A pipe ends a method's argument.** `'?.n?.toFixed?.wrap'` calls `toFixed()` with no argument, then `wrap`. `'?.n?.toFixed?.2?.wrap'` calls `toFixed('2')`, then `wrap`. - **`aka` aliases can point at pipes.** `$root` / `$parent` paths can use them too: `'$root?.title?.upper'`. - **A bare pipe name is a literal string.** Only segments inside a `?.` path are piped. ## Where Pipes Work Anywhere an RHS path is resolved: - plain values, arrays and nested objects; - inside ` =&` sync ops (`join`); - ` ?=` ternary conditions, results and comparison operands; - handler `get` / `resolve` maps; - `getValue` / `getValues` / `resolveValues` with `{ pipes }`; - both `assignFrom` and `assignFromAsync`. Nested call sites pass `pipes` along: - **`builtIns.tree`** passes them to every pattern; - **`builtIns.manageTemplateList`** passes them to per-item (`fromEachItem`) and `fromHost` patterns, even though per-item calls otherwise only receive their own `withOptions`. ## Common Patterns **Formatting a whole object:** ```JavaScript pipes: { coreSummary: ({ serial, flight, reused }) => `${serial}, flight ${flight}${reused ? ' (reused)' : ''}` } // textContent: '?.coreSummary' ``` **Choosing a formatter with a ternary** (here, based on UI state on the root VM): ```JavaScript 'textContent ?=': ['$root?.ui?.metric', '?.height?.metersLabel', '?.height?.feetLabel'] ``` **Turning an object into an array for a list handler:** ```JavaScript pipes: { linkList: links => Object.entries(links).filter(([, href]) => href).map(([name, href]) => ({ name, href })) } // resolve: { forEach: '?.linkList', … } ``` See [demos/tree-demo.html](../demos/tree-demo.html) for all of these together. ## Data Pipes (opt-in): pipes described as data Pipes are functions, and functions can't be stored (IndexedDB, `postMessage`, `structuredClone`) or written in a JSON file. That matters when a config travels: for example, to [o2h's service worker](https://github.com/bahrus/o2h#service-worker-first-paint), which renders it off the main thread. **Data pipes** are pipes described by small objects instead, which survive the trip. ### The third argument as JSON A `pipes` option can hold those objects, called descriptors, directly. So the whole third argument can be JSON, and **you never call a function to convert it:** ```json { "from": { "lines": [ { "quantity": 2, "price": 109.95 }, { "quantity": 1, "price": 22.3 } ] }, "pipes": { "items": { "sum": "?.quantity" }, "subtotal": { "sum": ["?.quantity", "?.price"] }, "usd": { "intl": "NumberFormat", "locale": "en-US", "options": { "style": "currency", "currency": "USD" } }, "total": { "chain": ["subtotal", "usd"] } } } ``` With that saved as `options.json`, the code needs one import for its side effect, and nothing else: ```js import { assignFrom } from 'assign-gingerly/assignFrom.js'; import 'assign-gingerly/pipes/dataPipes.js'; // imported, never called import options from './options.json' with { type: 'json' }; const summary = {}; assignFrom(summary, { '?.count': '?.lines?.items', '?.amount': '?.lines?.total' }, options); // summary: { count: 3, amount: '$242.20' } ``` **How the descriptors become pipes:** importing the module makes it available to the core. The first path that reaches a descriptor (here, `?.lines?.items`) converts the whole `pipes` object, and the result is kept for that object, so later calls reuse it. That's why `total` can chain to its siblings by name. Paths use descriptors exactly as they use functions, and nothing changes in the pattern. `assignFromAsync` doesn't need the import, because it can wait for a load. The extension doesn't either, because it includes the module: ```js import { assignFromAsync } from 'assign-gingerly/assignFromAsync.js'; import options from './options.json' with { type: 'json' }; await assignFromAsync(summary, { '?.count': '?.lines?.items', '?.amount': '?.lines?.total' }, options); ``` ```js import 'assign-gingerly/assignFrom-extension.js'; import options from './options.json' with { type: 'json' }; const summary = {}.assignFrom({ '?.count': '?.lines?.items', '?.amount': '?.lines?.total' }, options); ``` The options don't have to be in a file: an object literal, or a parsed string from `postMessage` or a database, works the same way. The JSON form is also how [o2h](https://github.com/bahrus/o2h#data-pipes-survive) carries pipes from an app's JSON to a worker. ### What it costs The module is about 1.4 KB, minified and gzipped. **The core doesn't import it,** so a page that uses no data pipes doesn't pay for it. Which entry point you use decides when you do: | You use | The 1.4 KB | |---|---| | `assignFrom`, `getValue`, `getValues`, `resolveValues` | **Your choice:** the `import` above, once, before the first call. These don't load it themselves, so without it a descriptor throws an error saying so. Function pipes work either way. | | `assignFromAsync` | **Loaded for you,** with a dynamic `import()`, when it first sees a descriptor. Never loaded for function pipes. A bundler that splits code makes it a separate chunk; one that doesn't inlines it. | | `assignFrom-extension.js` (`obj.assignFrom()`) | **Always included:** it's synchronous, so it can't load the module on demand. | A `pipes` object is read once, so don't change it afterwards. ### Calling `dataPipes()` yourself This is optional. It makes the pipes ahead of time, and it reports mistakes sooner: an unknown descriptor, `intl` kind, built-in or chain name throws when you call it, where in a `pipes` option it throws when the first descriptor is used. ```js import { dataPipes } from 'assign-gingerly/pipes/dataPipes.js'; const pipes = dataPipes({ longUtc: { intl: 'DateTimeFormat', locale: 'en-US', options: { dateStyle: 'long', timeZone: 'UTC' } }, kg: { intl: 'NumberFormat', locale: 'en-US', options: { style: 'unit', unit: 'kilogram' } }, phase: { map: { hold: 'On hold', go: 'Go for launch' }, default: 'Unknown' }, price: { use: 'toFixed', args: [2] }, when: { chain: ['parseDate', 'longUtc'] }, items: { sum: '?.quantity' }, clock: s => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`, // functions pass through }); // '?.date_utc?.when', '?.mass?.kg', '?.status?.phase' … ``` **`dataPipes(entries)`** returns ordinary pipes: functions are kept as they are, and each descriptor becomes a function. So data pipes work wherever pipes do, synchronously (paths, builtIns.tree, sync ops, ternaries). A `pipes` option can mix functions and descriptors the same way, with or without this call, though a function means it's no longer JSON. ### The descriptors | Kind | Descriptor | Does | |---|---|---| | **`intl`** | `{ intl: 'DateTimeFormat' \| 'NumberFormat' \| 'RelativeTimeFormat' \| 'PluralRules' \| 'ListFormat', locale?, options? }` | An `Intl` formatter, built once per descriptor. `DateTimeFormat` accepts ISO strings (as JSON carries dates), dates and timestamps. `RelativeTimeFormat` takes a `unit` (default `'day'`). `PluralRules` takes `cases` (`{ one: '…', other: '…' }`; `other` is the fallback). | | **`map`** | `{ map: { … }, default? }` | A lookup by `String(value)`: n-way labels or class names, where a ternary only does two ways | | **`use`** | `{ use: name, args? }` | A built-in: `parseDate`, `number`, `string`, `trim`, `upper`, `lower`, `length`, `toFixed`, `slice`, `join`, `default`. The value comes first, then `args`. | | **`chain`** | `{ chain: [name, …] }` | Pipes in sequence, by name: other pipes of the same object (descriptors **or functions**), or built-ins. It stops at `null` / `undefined`, as a path does. | | **`sum`** | `{ sum: '?.path' }` or `{ sum: ['?.a', '?.b', …] }` | A total over an array: each element's path added up, or with several paths, their **product** per element (quantity × price). The paths are ordinary `?.` paths, resolved against each element with the same pipes. An element whose path leads nowhere, or isn't a number, adds 0 (numeric strings count). An empty array totals 0; a value that isn't an array gives `undefined`. | | **`min`**, **`max`** | `{ min: '?.path' }`, `{ max: '?.path' }` | The smallest or largest of an array's values at the path per element, e.g. to scale a chart. Only numbers and numeric strings count: an element whose path leads nowhere, to a blank, or to anything else (`"-"`) is skipped. No numbers at all, or a value that isn't an array, gives `undefined`. | - **Bad chains:** a chain that names itself (directly or not) throws a clear error instead of recursing forever. - **Locale:** without `locale`, `Intl` uses the runtime's default, which can differ between a server or worker and the browser. Give one when the server renders. - **Each pipe made from a descriptor carries it,** under the registered symbol `Symbol.for('assign-gingerly.pipeDescriptor')` (also exported as `PIPE_DESCRIPTOR`, with `pipeDescriptorOf(pipe)`). That's how o2h's `prepareO2HTransform` stores the descriptor instead of losing the pipe, so the worker renders what uses it. - **Totals:** `?.length` counts an array's elements; `sum` counts what they hold. In the JSON above, `subtotal` is 2 × 109.95 + 1 × 22.3, which is 242.2 give or take float noise. `total` chains `usd` onto it, and formatting rounds the noise away. - **A chart's scale:** `{ min: '?.value' }` and `{ max: '?.value' }` give a series' range, which CSS can scale bars with (o2h-edge's BLS demo): ```js const pipes = { low: { min: '?.value' }, high: { max: '?.value' } }; //
//
//
// li { height: calc((var(--v) - var(--low)) / (var(--high) - var(--low)) * 100%); } ``` - **Deliberately small:** no comparisons, other arithmetic (beyond `sum`, `min` and `max`), filtering or sorting. Pipes that need those stay functions, and in a worker, what uses them is left to the client.