Select Tagged Values from JSON Object Entries
PostgreSQL can preserve the fixed key and value positions produced by
object:items() when the schema and stored JSON category identify the value
type. This recipe filters a JSONB map of nullable Bytes values by key and
decoded byte length.
CREATE TABLE artifacts (
id bigint PRIMARY KEY,
attachments jsonb
);
INSERT INTO artifacts VALUES
(1, '{"release-manifest":"YQ=="}'),
(2, '{"draft":"YQ=="}'),
(3, '{"release-manifest":""}'),
(4, '{"release-manifest":null}'),
(5, 'null');
Declare attachments as Map<Bytes|null>|Null, bind it to the JSONB column,
and compile:
attachments:items:any(item=>item[0]:startswith("release-") item[1]:len>0)
Only row 1 matches: its key starts with release- and its decoded Bytes value
is nonempty. The items() callback receives [String key, Bytes|null value];
the backend retains those roles through the literal tuple indexes. JSON stores
Bytes as canonical padded standard-base64 text, so "YQ==" represents the
single byte a. A stored null remains an ordinary null value and does not
match len>0.
Run the example program from the repository root with:
go run ./examples/sql-json-items
It prints the generated PostgreSQL predicate and arguments, then the Local results for the same records. Building the SQL does not connect to a database.
Project a recoverable field from an object union
A query can select a uniquely typed field from an object branch without normalizing an unrelated field whose stored String representation is ambiguous:
CREATE TABLE projection_events (
id bigint PRIMARY KEY,
labels jsonb NOT NULL
);
INSERT INTO projection_events VALUES
(1, '{"app":"auth","opaque":"YQ=="}'),
(2, '{"app":"billing","opaque":"Yg=="}'),
(3, '0'),
(4, '["YQ=="]');
Use a category-disjoint root union. The opaque field intentionally has
ambiguous String-or-Bytes identity; the app field remains a known String:
objectType := schema.Struct(
schema.String("app"),
schema.Field("opaque", schema.Union(schema.StringType, schema.BytesType)),
)
labelsType := schema.Union(
objectType,
schema.IntType,
schema.ListOf(schema.BytesType),
)
Configure the schema and binding, then build the first predicate:
e, err := engine.New(
engine.WithSchema(schema.Struct(schema.Field("labels", labelsType))),
engine.WithCapabilities(capabilities.Standard()),
)
if err != nil {
return err
}
program, err := e.Compile(ctx, `labels.app="auth"`)
if err != nil {
return err
}
query, err := hffsql.Build(ctx, program,
hffsql.WithDialect(hffsql.PostgreSQL),
hffsql.WithBindings(hffsql.BindJSON(
hff.Path{"labels"},
hffsql.Column("projection_events", "labels"),
hffsql.JSONB,
)),
)
if err != nil {
return err
}
Build the second predicate with the same binding. Both predicates match only row 1:
labels.app="auth"
labels:get("app")="auth"
The root JSON category selects the object branch, and the selected app
field has a recoverable String type. Selecting opaque or serializing the
whole labels value still requires distinguishing String from Bytes and may
be rejected by Build. A String in an Any field also remains a String; its
spelling does not imply Bytes.
Root type inspection and shape guards can observe the category without
decoding the ambiguous opaque field. These queries use the same table,
schema, and binding:
labels:type="object"
labels:[object].app="auth"
labels:[map[any]]:get("app")="auth"
labels:[int]=0
labels:[list[bytes]]:size=1
The first matches rows 1 and 2. The second and third match only row 1; the
fourth matches row 3 and the fifth row 4. After an object guard, app retains
its declared String type. The shape checks do not make opaque recoverable,
and an Any string still does not imply Bytes. Operations that require
numeric distinctions lost by JSONB still need type metadata.
Preserve field types in an object alternative
A schema-known object branch can retain each field's type even when the root JSON value may instead be an integer, a byte list, or null:
CREATE TABLE typed_events (
id integer PRIMARY KEY,
labels jsonb
);
INSERT INTO typed_events VALUES
(1, '{"app":"auth","asset":"YQ=="}'),
(2, '{"app":"billing","asset":"Yg=="}'),
(3, '0'),
(4, '["YQ=="]');
Declare the alternatives by their JSON categories:
objectType := schema.Struct(
schema.String("app"),
schema.Bytes("asset"),
)
labelsType := schema.NullableType(schema.Union(
objectType,
schema.IntType,
schema.ListOf(schema.BytesType),
))
e, err := engine.New(
engine.WithSchema(schema.Struct(schema.Field("labels", labelsType))),
engine.WithCapabilities(capabilities.Standard()),
)
if err != nil {
return err
}
Compile and build each query with labels bound to
hffsql.Column("typed_events", "labels") using hffsql.BindJSON and the
hffsql.JSONB format. These queries match rows 1 and 2:
labels:values:map(value=>value:type):json=["string","bytes"]:json
["app","asset"]:map(key=>labels:get(key)):map(value=>value:type):json=["string","bytes"]:json
The first enumerates the two declared fields. The second looks up fields with keys produced at runtime and preserves each selected field's declared type. Rows 3 and 4 have non-object roots, so the object operations return null and do not match. A dynamic numeric index can also select a list alternative; this query matches row 4 only:
[0]:any(key=>labels[key]:[bytes]:base64="YQ==")
The index expression is evaluated for every non-null root. An incompatible
root, a null index, or a wrong-family index returns ordinary null; an index
expression that produces no-value remains no-value. In contrast,
get() skips its key expression when the root is not an object. This relies on
the declared, category-disjoint alternatives and does not recover an
ambiguous field or infer a tagged type from a string inside Any.
Serialize guarded item values
The typed_events object has a String app field and a Bytes asset field.
These queries keep the value's per-key type through a list guard, then serialize
the selected values in key order:
labels:items:map(pair=>pair:[list]![1]):json="[\"auth\",\"YQ==\"]"
labels:items:[list[list]]:map(pair=>pair[1]):json="[\"auth\",\"YQ==\"]"
Both match row 1. The first guards each pair before reading its value; the
second guards the complete item list before mapping. json() writes Bytes in
canonical base64 form, so asset serializes as "YQ==" without becoming a
String during the guard. Rows 3 and 4 have non-object roots and do not match.
Missing keys, ordinary null, and guard-produced no-value retain their distinct
behavior. This bounded item-value path does not make an ambiguous materialized
value recoverable; see the PostgreSQL JSON guide.
Guard recovered pair shapes
The fixed roles also let a type guard inspect a pair without treating its two
positions as one undifferentiated union. With the Map<Bytes|null> binding
above, these guards distinguish the String key from the Bytes-or-null payload:
attachments:items[0]:[list[string]]:exists
attachments:items[0]:[list[bytes]]:exists
attachments:items:[list[list[string]]]:exists
The first guard is false for a Bytes payload and for a null payload; the second
is false because the key is String. The outer guard is false for a nonempty
map, since each pair contains a Bytes-or-null value, and true for an empty map.
If the binding instead declares Map<String|null>, the pair guard
attachments:items[0]:[list[string|null]]:exists admits a null payload, while
[list[string]] does not. Guard results for null receivers follow the target
type, and no-value remains distinct from ordinary null. These checks require
the same recoverable source schema as pair-slot access; they do not make
ambiguous same-category alternatives recoverable.
Observe a pair slot with a dynamic index
Pair callbacks can inspect a slot's type or existence, and can apply a type guard before a typed operation. This query checks the key and value positions using a list of valid numeric indexes:
attachments:items:any(pair=>pair[0]:type="string")
attachments:items:any(pair=>pair[1]:[bytes]:exists)
attachments:items:any(pair=>[0,1,-1,-2]:any(i=>pair[i]:[bytes]:len>0))
The first query matches rows 1–4, whose non-null maps contain String keys. The
second matches rows 1–3, whose values are Bytes; the null value in row 4 does
not match. The third matches rows 1 and 2 because at least one of the key/value
indexes selects nonempty Bytes. Integer-valued Float indexes and negative
indexes are also valid. Fractional and out-of-range numbers produce ordinary
null, as do nonnumeric runtime values of a union or Any index. Statically
incompatible index types remain compile errors. If evaluating the index
produces guarded no-value, that state remains distinct; a null pair receiver
masks a later index guard.
Consecutive guards can inspect and narrow an ambiguous value slot while retaining its original per-key role:
attachments:items:any(pair=>pair[1]:[string|bytes|null]:type="bytes")
attachments:items:any(pair=>pair[1]:[string|bytes|null]:[bytes]:len>0)
attachments:items:any(pair=>pair[1]:[null]:[any]:type="null")
The first matches rows 1–3, where the payload is Bytes. The second narrows the
same slot to Bytes before checking its length and matches rows 1 and 2. The
third matches only row 4, whose payload is null. A failed guard produces
no-value, which a later null-accepting guard cannot revive. exists() reports
false and type() remains no-value after that failure. These observation-local
chains do not make an ambiguous slot safe to return as a general value; the
source schema and representation must still recover the original key/value
roles.
The backend preserves these positional roles when the source items() result
and schema establish them. Constant indexes 0 and -2 select the String
key; 1 and -1 select the value. Identity map() and pair-only flatmap()
callbacks can preserve the roles, including when they duplicate pairs. Same-
category alternatives whose identity cannot be recovered and Any strings
requiring Bytes identity remain unsupported. Missing keys, stored null, and
guard-produced no-value retain their separate HFF behavior. See the
PostgreSQL JSON guide
for the supported subset and its limits.
Keep Any strings distinct from tagged fields
Per-key schema identity also lets an Any field sit beside fields with tagged
string representations. In this example app is Any|null, while the other
fields retain their declared types:
CREATE TABLE events (
id bigint PRIMARY KEY,
labels jsonb,
labels_meta jsonb NOT NULL DEFAULT '{"version":1,"types":{}}'::jsonb
);
INSERT INTO events (id, labels) VALUES
(1, '{"app":"YQ==","asset":"YQ==","elapsed":"0.000000001s","recorded":"2026-10-15T00:00:00Z"}'),
(2, '{"app":"2026-10-15T00:00:00Z","asset":"Yg==","elapsed":"1s","recorded":"2026-10-15T00:00:01Z"}'),
(3, '{"app":"","asset":null,"elapsed":"2s","recorded":"2026-10-15T00:00:02Z"}'),
(4, '{"app":null,"asset":null,"elapsed":null,"recorded":null}');
Use this schema and binding (with the usual engine and PostgreSQL setup):
schema.Struct(schema.Nullable(schema.Field("labels", schema.Struct(
schema.Nullable(schema.Field("app", schema.AnyType)),
schema.Nullable(schema.Bytes("asset")),
schema.Nullable(schema.Duration("elapsed")),
schema.Nullable(schema.Timestamp("recorded")),
))))
hffsql.BindJSON(
hff.Path{"labels"},
hffsql.Column("events", "labels"),
hffsql.JSONB,
hffsql.WithTypeMetadata(hffsql.Column("events", "labels_meta")),
)
The empty version-1 metadata is valid for these string and null values. If
app contains numbers, populate its metadata with their numeric subtypes;
an empty types map cannot restore those subtypes from JSONB. The first two
queries inspect the app value as Any, then narrow it to String. The third
observes the separate declared Bytes field; it checks its HFF type, not its
length:
labels:items:any(pair=>pair[0]="app" pair[1]:[any]:type="string")
labels:items:any(pair=>pair[0]="app" pair[1]:[any]:[string]:len>0)
labels:items:any(pair=>pair[0]="asset" pair[1]:[any]:type="bytes")
The first matches rows 1–3 because their non-null app values are strings.
The second matches rows 1 and 2: the base64-looking and Timestamp-looking
texts in Any are still Strings, while the empty string in row 3 has length
zero. The third matches rows 1 and 2 because their asset fields are declared
Bytes; row 4's null field does not match. An Any string does not
acquire Bytes, Duration, or Timestamp identity from its spelling; identity
comes from the separate field's schema.
Preserve types in explicit constructor children
An explicit list constructor keeps the declared type of each child at its
position. These queries combine the Bytes asset and Duration elapsed
fields, then inspect, map, or search the constructed list:
[labels.asset,labels.elapsed][0]:type="bytes"
[labels.asset,labels.elapsed]:map(value=>value:type):json=["bytes","duration"]:json
labels.asset:exists [labels.asset,labels.elapsed]:contains(labels.asset)
All three match rows 1 and 2, where asset is non-null Bytes and elapsed is
Duration. Rows 3 and 4 have a null asset and do not match. The types come
from the two declared fields, not from their JSON string spellings. This
bounded support does not infer tagged identity from an Any string or make an
ambiguous stored union recoverable.
Select a declared field with a dynamic key
A single declared Struct can select a field using a String key supplied by a
callback. These examples try the asset and elapsed fields with both
get(key) and dynamic bracket access:
["asset","elapsed"]:any(key=>labels:get(key):type="bytes")
["asset","elapsed"]:map(key=>labels[key]:type):json=["bytes","duration"]:json
Both match rows 1 and 2. The first query finds the Bytes asset field; the
second observes the declared Bytes and Duration types in order. Rows 3 and 4
do not match because asset is null. The key is evaluated once, and the
selected field keeps its own declared type. This applies to a schema-known
single or nullable Struct and explicit objects, not to unresolved Struct
unions or same-position ambiguous alternatives.
The selected field's declared type can also flow into a callback consumer or
a nested flatmap() predicate:
["asset","elapsed"]:map(key=>labels:get(key)):count(value=>value:type="bytes")=1
["asset","elapsed"]:any(key=>[labels:get(key)]:flatmap(value=>[value]):any(value=>value:type="bytes"))
Both match rows 1 and 2, where asset is non-null Bytes and elapsed is
Duration. Rows 3 and 4 do not match because asset is null and elapsed is
not Bytes. The declared field schema supplies Bytes identity; a string in
native JSON Any does not acquire that identity from its spelling. A
no-value produced by an earlier map guard is not erased by a later filter.
Enumerate heterogeneous Struct values
values() keeps each present field's declared type while enumerating a
schema-known heterogeneous Struct or explicit object. With the events
fixture, app, asset, elapsed, and recorded are enumerated in C
collation order:
labels:values:any(value=>value:type="bytes")
labels:values:map(value=>value:type):json=["string","bytes","duration","timestamp"]:json
Both queries match rows 1 and 2. The first finds the non-null Bytes value; the
second checks the ordered field types. Row 3 retains its explicit null asset
value, so its type list contains "null"; row 4 retains four null values.
Missing fields would be omitted. A string in the Any app field remains a
String even when its text resembles base64 or a Timestamp. This does not infer
tagged identity from native JSON Any strings or resolve ambiguous stored
unions.
Continue a selected slot through a map chain
A direct map/filter sequence can retain each selected value's field identity
through later maps and predicates. With the schema and rows above, these
queries inspect the type of each items() value:
labels:items:map(pair=>pair[1]):map(value=>value:type):contains("bytes")
labels:items:map(pair=>pair[1]):any(value=>value:type="bytes")
labels:items:map(pair=>pair[1]):count(value=>value:type="string")=1
labels:items:map(pair=>pair[1]!):any(value=>true)
The first two match rows 1 and 2, whose asset values are Bytes. The count
query matches rows 1–3, each of which has one String in its app field. The
last query matches rows 1 and 2; row 3's null asset value makes the earlier
map produce no-value, so the later constant-true predicate cannot hide it.
This pipeline keeps the selected slot's schema through direct map/filter
sequences. Filters can remove rows before later maps, but their guards do not
change the retained value's type. An earlier map's no-value remains a failure
even if a later filter would remove that row. The eligible concat and bounded
flatmap() compositions described below can also carry these slots to a
consumer. Folds can also preserve recovered values in constructed typed trees;
the aggregate section below shows the bounded numeric case. Other materialized
values still need recoverable type information.
For example, filtering by observed type removes null payloads before the next map, while retaining the original field roles:
labels:items:map(pair=>pair[1]):filter(value=>value:type="bytes"):map(value=>value:type):contains("bytes")
labels:items:map(pair=>pair[1]):filter(value=>value:exists):map(value=>value!):count(value=>true)=3
labels:items:map(pair=>pair[1]!):filter(value=>false):all(value=>true)
The first matches rows 1 and 2, whose asset field is Bytes. The second
matches row 3: filtering removes its null asset, and the three remaining
values are app, elapsed, and recorded. The third matches rows 1 and 2,
whose mapped values contain no nulls. For rows 3 and 4, the first map's !
produces no-value; the later always-false filter cannot erase that failure.
Concatenate deferred branches for an immediate consumer
Eligible deferred items() branches can be directly concatenated and consumed
by map(), any(), all(), or count(predicate). This immediate map keeps
the branch order visible in the resulting type list:
labels:items:map(pair=>pair[1]):concat(labels:items:map(pair=>pair[1])):map(value=>value:type)=["string","bytes","duration","timestamp","string","bytes","duration","timestamp"]
Rows 1 and 2 match.
Continue a shared pipeline after concatenating deferred branches
An eligible direct concat can continue through an elementwise map/filter chain
and then a terminal map(), any(), all(), or count(predicate). Each
branch keeps its own key and schema while callbacks resolve the selected value,
and nested concat leaves preserve branch order. With the events schema and
rows above, this query filters nulls and maps each remaining value to its HFF
type name:
labels:items:map(pair=>pair[1]):concat(labels:items:map(pair=>pair[1])):filter(value=>value:type!="null"):map(value=>value:[any]):map(value=>value:type)=["string","bytes","duration","timestamp","string","bytes","duration","timestamp"]
Rows 1 and 2 match: each branch contributes string, bytes, duration,
timestamp in object-key order, and the branches remain left-to-right. Complete
item pairs can also be concatenated before a later map selects a slot. For a
deferred slot, json() observes and serializes each value using that key's
schema. Returning an unresolved ambiguous value directly from the terminal
map() still fails type recovery. A no-value from a map before concat remains
a failure even if later filtering excludes that row. If concat returns null,
it masks failures from all later maps; filters do not narrow their retained
values, and ordinary null remains data.
A map may also appear between two concat levels. The inner mapped values remain recoverable within the enclosing tree, while the final map emits ordinary type names:
labels:items:map(pair=>pair[1]):concat(labels:items:map(pair=>pair[1])):map(value=>value:[any]):concat(labels:items:map(pair=>pair[1])):map(value=>value:type)=["string","bytes","duration","timestamp","string","bytes","duration","timestamp","string","bytes","duration","timestamp"]
Rows 1 and 2 match. The values from the first concat level stay ahead of the third deferred branch, and each branch keeps its own item-slot schema.
The same item-slot path supports bounded flatmap() composition from a list
literal. This query duplicates each selected value before mapping it to its
type name:
labels:items:map(pair=>pair[1]):flatmap(value=>[value,value]):map(value=>value:type)=["string","string","bytes","bytes","duration","duration","timestamp","timestamp"]
Rows 1 and 2 match. Each object's values remain in key order, and every value
appears twice. Within this path, list literals, maps, filters, concat trees,
nested flatmap() calls, and recoverable list expressions can be composed.
A callback that returns a non-list makes the whole flatmap() result null;
guarded no-value propagates no-value. The first invalid callback result
determines the result, even if later callbacks return an empty list. Ordinary
null inside a returned list remains data. Fold carriers and unrelated
materialization boundaries remain outside this support.
Wrap and recover constructed values
Constructed lists and objects can retain the selected value's type through later operations. These queries compare the wrapped result with a direct type observation; all four example rows match, including rows with null values:
labels:items:map(pair=>pair[1]):map(value=>[value]):flatmap(values=>values):map(value=>value:type):json=labels:items:map(pair=>pair[1]):map(value=>value:type):json
labels:items:map(pair=>pair[1]):map(value=>{"payload":value}):map(object=>object:get("payload"):type):json=labels:items:map(pair=>pair[1]):map(value=>value:type):json
labels:items:map(pair=>pair[1]):map(value=>{"payload":value}):map(object=>object.payload:type):json=labels:items:map(pair=>pair[1]):map(value=>value:type):json
labels:items:map(pair=>pair[1]):map(value=>[value,value]):map(values=>values[-1]:type):json=labels:items:map(pair=>pair[1]):map(value=>value:type):json
labels:items:map(pair=>pair[1]):map(value=>[value]):map(values=>values:[list[any]]):flatmap(values=>values):map(value=>value:type):json=labels:items:map(pair=>pair[1]):map(value=>value:type):json
labels:items:map(pair=>pair[1]):map(value=>[value,null]):map(values=>values:size):all(size=>size=2)
These examples cover a list wrapper followed by flatmap(), object field
access through get(), literal-list indexing and type guards, and the size()
observer. The wrapper preserves each field's declared type, and ordinary null
remains a value inside the constructed collection. A guard-produced no-value
still propagates instead of being silently removed.
A later filter still cannot erase a no-value produced by an earlier map:
labels:items:map(pair=>pair[1]):concat(labels:items:map(pair=>pair[1])):map(value=>value!):filter(value=>false):all(value=>true)
Rows 1 and 2 match. Rows 3 and 4 have null values that make the earlier map produce no-value; the later always-false filter does not erase that failure.
Index deferred list results
An eligible list produced by a deferred item pipeline can be indexed before
its values are materialized. Indexes count retained outputs in full order,
after filters and flatmap() expansion:
labels:items:map(pair=>pair[1]):flatmap(value=>[value])[0]:type="string"
labels:items:map(pair=>pair[1]):flatmap(value=>[value])[1]:type="bytes"
labels:items:map(pair=>pair[1]):filter(value=>value:exists):flatmap(value=>[value])[labels:size%3-1]:type="string"
labels:items:map(pair=>pair[1]!):flatmap(value=>[value])[0]:type="string"
The first query matches rows 1–3, whose first value is the String app field;
the second matches rows 1 and 2, whose second value is Bytes. The third uses a
dynamic integral index. The object has four fields, so labels:size%3-1 is
zero: rows 1–3 match on their first retained String, while row 4 has no values
after the filter. The last query matches rows 1 and 2. Rows 3 and 4 still fail
because the whole-list map encounters null values and produces no-value, even
though the selected index is zero. Out-of-range and fractional indexes remain
ordinary null; a statically incompatible index remains a compile error.
Consume object keys without reading values
When an otherwise supported items() consumer uses only the fixed key slot,
PostgreSQL can traverse the object's keys directly without decoding values or
constructing [key, value] arrays. For example, with attachments bound as
Map<Bytes|null>|Null, this query checks whether any key starts with the
release prefix:
attachments:items:any(pair=>pair[0]:startswith("release-"))
Rows 1, 3, and 4 match because their keys start with release-, including the
rows whose Bytes value is empty or null. The key text is available without
decoding those values. The source expression still runs, and null/no-value
behavior is unchanged. This path requires a fixed key-slot access (0 or
-2) and does not apply when the callback reads the value, observes the full
pair/list shape, or returns pairs.
See the key-only traversal record
for the full eligibility boundary and SQL-shape evidence.
Preserve and combine complete pairs
For a non-null map, these queries show identity preservation, duplicate detection, and first-occurrence deduplication:
attachments:items:map(pair=>pair)=attachments:items
attachments:items:flatmap(pair=>[pair,pair]):unique
attachments:items:flatmap(pair=>[pair,pair]):distinct=attachments:items
The first query compares equal to the source. The second returns false when
the source has at least one entry because the transformed list duplicates
every pair; for an empty source it returns true. A null source makes these
collection methods return null; the equality comparisons and final filter
truthiness return false. unique() returns a Bool reporting
whether the list already has no duplicates. distinct() instead returns the
first-occurrence list, so the third query compares equal to the source. Role
proofs retain type information through duplication but do not imply
uniqueness. Folds or other callback results without a complete pair-role
invariant retain the general representation path.
Fold over deferred item values
Fold callbacks can retain recovered items() values inside constructed
accumulators, then inspect or serialize them later. With labels and the four
records above, the first query grows a list of mixed tagged values and observes
their types after the fold. The second keeps the latest value in an object
field:
labels:items:map(pair=>pair[1]):foldl([], (values,value)=>values:concat([value])):map(value=>value:type):json=["string","bytes","duration","timestamp"]:json
labels:items:map(pair=>pair[1]):foldl({}, (acc,value)=>{"previous":acc,"value":value}).value:type="timestamp"
The list query matches rows 1 and 2. The object query matches rows 1–3; row 4's last value is null. The PostgreSQL backend keeps the source schema with these constructed accumulator values, so later maps and field access can recover Bytes, Duration, and Timestamp instead of treating every stored string alike. A null value is passed to the callback rather than skipped. An empty list returns its seed, and a null receiver returns null before the seed or callback is evaluated. Guard-produced no-value propagates through the whole fold.
Enumerate a folded object's fields
The same typed values can be retained in an object and enumerated afterward.
Using typed_events and its four rows above, these queries keep the asset
Bytes value in a field named v:
labels:items:filter(pair=>pair[0]="asset"):foldl({"v":null},(acc,pair)=>{"v":pair[1]}):values:map(value=>value:type):json=["bytes"]:json
labels:items:filter(pair=>pair[0]="asset"):foldr({"v":null},(pair,acc)=>{"v":pair[1]}):items:map(pair=>pair[1]:type):json=["bytes"]:json
labels:items:filter(pair=>pair[0]="asset"):foldl({},(acc,pair)=>{"v":pair[1]}):keys:json=["v"]:json
All three queries match rows 1 and 2. Rows 3 and 4 have non-object roots, so the
items() receiver returns null and the fold and later enumeration return null.
The private fold result retains the Bytes type for values() and
items(); keys() only observes the fixed field name. This does not infer a
tagged type from an ambiguous stored JSON string.
Typed-tree in(), list contains(), unique(), and distinct() can use the
same recovered value identities as these comparisons. Bounded sorting of
recovered typed trees is shown in
Sort recovered deferred values. Aggregates
on folded typed lists are supported when their numeric element types can be
recovered, as shown below. Other consumers may still need an explicit guard or
fail Build when a value's type cannot be recovered. Native JSON strings do
not imply tagged identity, and JSONB still needs numeric metadata when storage
erased a distinction the operation depends on.
Compare a folded typed list
A folded typed tree can be compared directly with another tree from the same source, without serializing either list first:
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value]))=labels:items:map(pair=>pair[1])
All four events rows match. The lists preserve object-key order and the same
recovered value types; the stored null members in rows 3 and 4 compare equal
inside the lists. This is structural equality, so different element families
are unequal and mixed Int/Float values compare by exact numeric value. A
top-level dynamic type mismatch yields null, while comparisons involving
top-level null or no-value are false except for an explicit =null test.
Bounded sorting of recovered typed trees is shown in
Sort recovered deferred values.
The aggregate section below shows the supported numeric consumers. These
comparisons do not make every operation valid for every ambiguous tree.
Membership and distinct on folded typed values
Membership and deduplication can also consume a fold result while it retains its per-field types:
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value])):contains("YQ==","YQ==":frombase64)
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value])):concat(labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value]))):distinct:size=4
The membership query matches row 1, where app is the String "YQ==" and
asset is the Bytes value represented by that base64 text. The second query
matches rows 1–3: each has four distinct field values, with row 3's null
counting as one value. Row 4 has four nulls, which distinct() reduces to one.
These examples compare scalar values; structural list/object elements also
require the applicable equality capabilities. See the PostgreSQL JSON guide's
null and no-value behavior for contains()
for the difference between String and List receivers.
Sort recovered deferred values
sort() can consume a deferred list when its values retain an orderable type.
These queries fold each record's values into a typed list. The first retains
String values, appends an empty String, and sorts with PostgreSQL's C
collation; the second sorts the mixed list directly:
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value])):filter(value=>value:[string]:exists):concat([""]):sort:json="[\"\",\"YQ==\"]"
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value])):sort:type="null"
The first query matches row 1: its only String value is the app value
YQ==, and the appended empty String sorts before it. Row 2's app value is
the Timestamp-looking String, which remains a String but does not match the
expected list. The second query matches rows 1–4 because each folded list
contains mixed value families or null and therefore has no single runtime sort
domain. An empty list still sorts to an empty list. Deferred sorting preserves
the original values and stable order; it does not enable unrelated consumers
of every ambiguous tree.
Find the first typed deferred value
find() can return a recovered value from a folded list. These queries select
the first Bytes value, then the first Timestamp value, in each record's
object-key order:
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value])):find(value=>value:[bytes]:exists):string="YQ=="
labels:items:map(pair=>pair[1]):foldl([], (acc,value)=>acc:concat([value])):find(value=>value:[timestamp]:exists):type="timestamp"
The first matches row 1, whose Bytes value is YQ==; row 2's first Bytes
value is Yg==. The second matches rows 1–3, whose first Timestamp value is
the recorded field. Row 4 has no matching non-null value. A guarded
no-value predicate is false, and SQL preserves the first-match result without
promising Local's physical callback short-circuit.
Aggregate a folded typed list
These queries fold each key together with its original, heterogeneous value.
The aggregate callback reads only the String key and projects its length as
Int. The four keys app, asset, elapsed, and recorded have lengths 3, 5,
7, and 8, so all four records match each query:
labels:items:map(pair=>{"key":pair[0],"value":pair[1]}):foldl([], (acc,item)=>acc:concat([item])):sum(item=>item.key:len)=23
labels:items:map(pair=>{"key":pair[0],"value":pair[1]}):foldl([], (acc,item)=>acc:concat([item])):avg(item=>item.key:len)=5.75
labels:items:map(pair=>{"key":pair[0],"value":pair[1]}):foldl([], (acc,item)=>acc:concat([item])):min(item=>item.key:len)=3
labels:items:map(pair=>{"key":pair[0],"value":pair[1]}):foldl([], (acc,item)=>acc:concat([item])):max(item=>item.key:len)=8
The folded list retains each Bytes, Duration, Timestamp, String, or null value; only the numeric key-length projection contributes to the aggregate. For typed trees with Float contributions, PostgreSQL uses the backend Float aggregate profile described in the JSON backend guide: numeric intermediate accumulation and a checked final Float conversion. Null and guarded no-value contributions are skipped; a reached nonnumeric contribution makes the result null. A no-value result for the whole source remains no-value. JSONB still requires numeric metadata when storage erased a numeric subtype needed by the aggregate.
Fold pair and pair-list accumulators
Folds can retain the same pair roles when the seed and every possible accumulator result maintain one compatible pair or pair-list shape:
attachments:items:foldl(null,(acc,pair)=>pair):json=attachments:items[-1]:json
attachments:items:foldr(null,(pair,acc)=>pair):json=attachments:items[0]:json
attachments:items:foldl([], (acc,pair)=>acc:concat([pair])):json=attachments:items:json
attachments:items:foldr([], (pair,acc)=>[pair]:concat(acc)):json=attachments:items:json
For a nonempty map, the first two queries select the last and first pair
respectively. The latter two build a list of pairs in source key order. On an
empty list, a fold returns its seed (null or [] in these examples). An
ordinary null result becomes the next accumulator; it does not skip an item.
A reached guarded no-value still propagates through the entire fold. concat()
keeps pair-list roles when both operands have compatible provenance; an empty
list or null operand may be neutral for this proof only. A null concat operand
still makes the runtime result null. If the seed, callback result, or combined
list can originate from an ambiguous schema such as String versus Duration,
the backend retains general lowering and may reject operations that require the
unrecoverable tagged identity. Pair-role provenance never changes the public
type or capability contract.