Skip to content

Values, Identity, Equality, and Collections

Canonical source: docs/guide/05-values-identity-equality-and-collections.md at locked revision e99b712ecf658a527b1fe82c8beba2c1286d8dc9. This rendered page is non-normative; the applicable Protos specification remains authoritative.

Status: Non-normative programming guide

Normative owners: spec/semantics/VALUES_AND_COLLECTIONS.md, spec/semantics/OBJECT_MODEL.md, spec/semantics/CALLABLES.md, and spec/PROTOS_GRAMMAR.md

The previous chapters established that Protos uses one object model, ordinary lookup, ordinary Closures, and ordinary protocols even for control flow. Values and collections follow the same principle.

The central idea is:

Every Protos value participates in the object model, but not every value has the same identity law or the same storage protocol.

Understanding that distinction prevents several common mistakes:

  • null means a real value, not a failed lookup;
  • === asks about semantic identity, not customizable equality;
  • == is behavioral and may be customized;
  • postfix receiver[index] is an indexing protocol, while primary [items...] is ordinary-Array(...) construction sugar;
  • Map and IdentityMap intentionally use different key laws;
  • Core collections and Standard Library algorithms are different layers.

Core has one canonical null value.

x: null

means that the binding x exists and contains null.

That is different from a missing lookup:

missingName

A failed lookup signals an Error. It does not silently produce null.

This distinction is useful whenever absence is part of application data:

result: null

The program can deliberately store and return null while still relying on lookup failure to expose missing names and slots.

Core has exactly two semantic Boolean values:

true
false

They are canonical singleton values.

There is no standard Boolean prototype whose descendants automatically become Booleans. Delegating to true, false, or an object that exposes similarly named selectors does not grant Boolean membership.

This matters for every standard operation that requires a Boolean result:

==
<
<=
>
>=
and
or
while

The standard contract requires canonical true or canonical false; there is no truthiness conversion.

The primitive identity operator is:

a === b

=== is not a user-overridable protocol.

For an ordinary identity-bearing object, identity means that both expressions denote the same semantic individual:

a: { value: 1 }
b: { value: 1 }
c: a
a === b // false
a === c // true

The fact that two objects currently have the same visible slots does not make them the same object.

Identity is semantic rather than a JVM-address, allocation, wrapper, or cache property. An implementation can change representation without changing the answer to ===.

Core v0.1 has a closed set of value-identity families.

The semantic value itself determines identity for:

  • Number values;
  • String values;
  • canonical true;
  • canonical false;
  • canonical null.

For example:

1 === 1
"hello" === "hello"
("hel" + "lo") === "hello"
true === true
null === null

Those results do not depend on whether the implementation interns, boxes, caches, or reconstructs the values.

The set is deliberately closed. Properties such as these do not grant value identity to another object:

immutable
closed
frozen
structurally equal
interned by an implementation
backed by shared host storage

Arrays, Maps, IdentityMaps, Bytes, Closures, Futures, Errors, execution contexts, module instances, and ordinary objects remain identity-bearing unless a normative rule explicitly says otherwise.

Delegation does not transfer value identity

Section titled “Delegation does not transfer value identity”

An ordinary object can delegate to a value object without becoming that value family.

Conceptually:

wrapped: (42) {
label: "answer"
}

wrapped remains an ordinary identity-bearing object. Its parent relationship does not make:

wrapped === 42

true.

This is another example of Protos separating delegation from classification. Inheritance of behavior is not conversion of semantic identity.

String operations do not mutate a String in place.

When an operation produces different text, the result is another String value:

a: "hel"
b: a + "lo"
b === "hello"

Because Strings use value identity, an implementation may share or intern their storage freely without exposing that choice through ===.

Every semantic String value receives its standard behavior through the standard String delegation path. An ordinary object that merely delegates to String is still not itself a semantic String value.

Numbers follow the same identity principle:

x: 40 + 2
x === 42

The identity observation is about the semantic numeric value, not about a particular host numeric object.

Different numeric representations may still belong to more specific numeric protocols, but the programmer should not reason about === using JVM boxing, cache ranges, addresses, or allocation behavior.

The detailed numeric model remains owned by the normative specification. The important Programming Guide rule here is that Number identity is value-based.

The two operators are intentionally different.

===
non-overridable semantic identity
==
customizable semantic equality protocol

For ordinary objects:

left: { value: 1 }
right: { value: 1 }
left === right

is false unless both names denote the same semantic individual.

left == right, however, depends on the selected equality behavior.

A type-like prototype may define equality in terms of application state:

Point: {
==: (other) => {
(x == other.x) &&
(y == other.y)
}
}

That changes equality behavior. It does not change identity.

A == implementation must normally return exactly:

true

or:

false

Returning an arbitrary object, null, or a Future is not a truthy/falsy equality result. It violates the equality-result contract and signals Error at the applicable standard boundary.

The same strict Boolean-result principle applies to the standard ordering operators:

< <= > >=

This makes comparison code predictable because callers never have to guess how an arbitrary object should be coerced to a Boolean.

Hashing supports keyed protocols, but a hash integer is never proof of identity.

Two distinct objects may legally have the same hash.

So this reasoning is invalid:

hash(a) == hash(b)
therefore
a === b

Likewise, an identity hash is not required to be globally unique.

Identity is decided by identity semantics. Equality is decided by equality semantics. Hashing is an indexing aid used by protocols such as Map.

Keep those three concepts separate.

Postfix bracket syntax is an ordinary indexing protocol

Section titled “Postfix bracket syntax is an ordinary indexing protocol”

Indexed read:

receiver[index]

means the indexing operation:

receiver.at(index)

Indexed write:

receiver[index] = value

means:

receiver.atPut(index, value)

The syntax does not bypass lookup and does not impose one universal collection model.

Any object can participate by implementing the corresponding protocol.

This distinction is fundamental:

object.name

uses slot/member lookup.

This:

object["name"]

uses the at indexing protocol.

They are not automatically equivalent.

An object can even support both with unrelated state:

map.description: "users"
map["description"] = someUser

The first operation addresses a slot. The second addresses indexed contents.

Adding a slot does not make an object indexable, and implementing at does not turn indexed entries into slots.

Slot creation uses ::

object.name: value

There is no indexed counterpart:

object[index]: value

That form is invalid.

Indexed mutation is expressed through atPut, directly or through [] =.

Whether atPut inserts, replaces, rejects, extends, or requires an existing position belongs to the receiver’s protocol.

This lets Array and Map use the same bracket syntax while retaining different invariants.

The standard prelude Array object acts as a factory through ordinary call semantics:

Array()
Array(10)
Array(10, 20, 30)

Protos also provides bracket construction syntax:

[]
[10]
[10, 20, 30]

This is syntax sugar, not another collection mechanism. The lowering is exact:

[] -> Array()
[10] -> Array(10)
[10, 20, 30] -> Array(10, 20, 30)

Spread is the existing call spread:

middle: [20, 30]
values: [10, ...middle, 40]

and nested/postfix forms compose normally:

nested: [[10], 20]
first: nested[0][0]

Array remains an ordinary lookup. Bracket construction does not secretly capture the standard-prelude factory:

Array: (a, b) => a * 100 + b
[4, 2] // 402

So [a, b] behaves exactly like Array(a, b) under the binding visible at that point. With the ordinary standard-prelude Array, each successful construction creates a fresh open Array identity whose indexed elements are the exact supplied values.

A very important rule is:

[3]

and equivalently:

Array(3)

mean:

one element
index 0 -> 3

They do not mean “allocate an Array of length 3”.

Core has no special numeric-length Array constructor, holes, comprehensions, repetition/fill form, or generic collection-literal conversion.

Array construction keeps the exact supplied objects:

item: { value: 1 }
items: Array(item)
items[0] === item

The element is not cloned or frozen merely because it was inserted into an Array.

This shallow behavior is consistent with ordinary Protos reference semantics.

For the standard Array protocol:

items.at(index)
items.atPut(index, value)
items.size()

the index is an Integer position.

Standard reads and writes require a valid in-range position. atPut replaces the indexed value at that position; it is not a generic “grow to arbitrary index” operation.

Bracket syntax can express the same protocol:

x: items[0]
items[0] = replacement

Again, indexed state is distinct from the Array object’s ordinary slots.

Collection state and slot state remain orthogonal

Section titled “Collection state and slot state remain orthogonal”

Object openness and collection mutation are related by each protocol’s own rules, not by a blanket interpretation of [] as slot mutation.

For example, the standard Array protocol can replace an existing indexed element through atPut when that Array state permits the operation even though indexed assignment is not slot assignment.

Freezing prevents mutation of mutable standard collection state.

The practical rule is:

Ask which protocol owns the state being changed.

For:

array.label = "x"

the answer is the slot model.

For:

array[0] = "x"

the answer is the Array indexed-state protocol.

The standard Array.each callback traversal observes a shallow pre-callback snapshot of the indexed elements.

Conceptually:

items.each((element) => {
consume(element)
})

means that the traversal set is established before user callbacks begin.

The snapshot is shallow: its elements are the same semantic element objects, not deep copies.

This makes mutation during callback execution easier to reason about: a callback may cause later state changes, but those changes do not retroactively redefine which element references were captured for that traversal.

The callback itself uses ordinary polymorphic invocation.

%{...} defines a Map’s initial associations

Section titled “%{...} defines a Map’s initial associations”

For the common equality-keyed case, Protos has concise populated Map construction:

m: %{
"language": "Protos"
"version": 1
}

Entries may also share a line with ;:

m: %{ "language": "Protos"; "version": 1 }

This syntax creates a standard Map and defines its initial associations. It is important not to read it as hidden repeated indexed assignment.

Construction first looks up ordinary Map. The selected call behavior must be the canonical standard Map factory behavior, either directly or inherited through ordinary delegation.

That means ordinary prototype specialization works:

MyMap: Map {
label: "custom"
}
Map: MyMap
m: %{ "answer": 42 }

The resulting Map is fresh standard Map state whose parent is MyMap.

A nearer custom call override is different. It does not become construction authority merely because it is named Map or happens to return an indexable object. %{...} rejects that factory before executing the custom call and before evaluating entries.

For every entry, Protos evaluates the key and then the value. Only after both finish normally does it define the initial association.

Initial definition uses normal Map hashing and directed equality, but it does not send atPut. An atPut override on an otherwise eligible Map prototype therefore cannot intercept initial construction.

Keys remain ordinary expressions:

key: "language"
m: %{
key: "Protos"
}

The key above is the value "language". A bare key name is not converted implicitly to a String.

Duplicate/equal initial keys are errors:

%{
"version": 1
"version": 2
}

does not mean “last value wins”. The second initial definition conflicts with the first under the normal Map key-selection law and signals Error.

This differs deliberately from later indexed mutation:

m: %{ "version": 1 }
m["version"] = 2

The second line is ordinary indexed assignment and therefore ordinary atPut. Standard Map mutation may replace the existing value there.

Map construction is a primary expression, so postfix indexing works directly:

%{ "answer": 42 }["answer"]

IdentityMap deliberately stays explicit:

identity: IdentityMap()

It has a different key law and receives no %{...} syntax. There is also no generic %Factory{...} form.

Map construction and structural Map.match(subject) are separate operations with separate semantics. %{...} constructs a Map value; it is not pattern syntax.

The standard Map family uses the key’s ordinary equality/hash protocols.

A typical Map:

m: Map()
m["language"] = "Protos"
m["version"] = 1
m["version"] = 2
m["language"]
m["version"]
m.size()

Updating an already-matching key replaces its value rather than creating a second entry.

Map preserves insertion order for its keyed traversal.

The key law is behavioral: finding a matching key involves the keyed hash/equality protocol rather than semantic object identity alone.

Because Map uses ordinary equality behavior, application-defined key types can participate.

If a key customizes equality, its hashing behavior must be designed coherently for keyed use. Map is not required to ignore hashing and linearly test every possible object.

This is a useful power but also a responsibility: mutable equality/hash state is a poor Map key design because changing the properties used by those protocols can undermine later lookup.

For identity-based keyed state, use IdentityMap instead.

IdentityMap deliberately uses a different law.

Consider two different objects with the same visible contents:

left: { value: 1 }
right: { value: 1 }
left === right // false

An IdentityMap treats them as distinct keys:

m: IdentityMap()
m[left] = "left"
m[right] = "right"
m.size() // 2

It does not call user-defined == to decide that those ordinary objects are the same key.

The operation relies on semantic identity plus identity hashing.

Value-identity keys still collapse by value in IdentityMap

Section titled “Value-identity keys still collapse by value in IdentityMap”

IdentityMap does not mean “physical allocation map”.

Because Numbers and Strings use value identity:

a: "hel" + "lo"
b: "hello"
a === b

is true.

Those values therefore denote the same semantic IdentityMap key.

This is exactly why the distinction between semantic identity and physical representation matters.

Map and IdentityMap solve different problems

Section titled “Map and IdentityMap solve different problems”

Use Map when key sameness should follow semantic equality:

"same customer number"
"same path value"
"same application key"

Use IdentityMap when key sameness should follow semantic identity:

this exact mutable object
this exact Closure
this exact identity-bearing capability/value

Neither is the “faster version” of the other at the language level. They expose different semantics.

The standard Map operations include:

at(key)
atPut(key, value)
containsKey(key)
remove(key)
size()
each(callback)

An absent at signals Error rather than returning null.

That keeps “missing key” distinct from:

m[key] = null

where the key exists and deliberately stores null.

Replacing an existing entry and adding a new key are semantically distinct. Adding/removing keyed structure requires the appropriate open mutable state; frozen state rejects mutation.

Map traversal preserves insertion order.

The standard each callback receives the key and value:

m.each((key, value) => {
consume(key, value)
})

Like other standard collection callbacks, invocation is ordinary protocol invocation rather than a second callable universe.

The traversal uses a keyed snapshot so callback effects do not rewrite the already-selected traversal sequence underneath the current iteration.

IdentityMap exposes the analogous keyed operations while using its identity key law.

Bytes is another identity-bearing collection

Section titled “Bytes is another identity-bearing collection”

Core Bytes represents indexed byte state.

It is not in the closed value-identity set, so a Bytes object remains an identity-bearing collection even when two Bytes currently contain the same byte sequence.

That is different from String:

String
immutable value identity
Bytes
identity-bearing indexed byte state

Encoding and byte-I/O behavior are covered in later guide material. The important point here is that “sequence-like” does not imply value identity.

Core collections and Standard Library algorithms are different layers

Section titled “Core collections and Standard Library algorithms are different layers”

Core provides the semantic collection mechanisms such as:

Array
Map
IdentityMap
Bytes

The Standard Library builds higher-level behavior using those mechanisms.

For example:

std:collections/Array

provides ordinary Protos algorithms such as mapping, filtering, indexed search, reduction, and sorting over Core Arrays, plus isolated-parallel variants of them composed from closure.parallel(...) and Futures (see chapter 09).

Those algorithms do not replace the Core Array family or introduce a second runtime Array representation.

Set and IdentitySet are Standard Library abstractions

Section titled “Set and IdentitySet are Standard Library abstractions”

Current Standard Library modules include:

std:collections/Set
std:collections/IdentitySet

They deliberately reuse existing keyed semantics:

Set
built on Map
membership follows equality-key semantics
IdentitySet
built on IdentityMap
membership follows identity-key semantics

There is no separate Core Set runtime family required merely because set operations are useful.

This is a good example of the Protos design principle “mechanisms over institutions”: higher-level collection abstractions can be written in ordinary Protos when Core already supplies the necessary semantic foundation.

Equality choice propagates into collection choice

Section titled “Equality choice propagates into collection choice”

A useful design sequence is:

What does "same" mean for my domain?
|
+-- semantic equality --> ==
| |
| +--> Map / Set
|
+-- semantic identity --> ===
|
+--> IdentityMap / IdentitySet

Choose the identity/equality law first. Then choose the collection whose key semantics match it.

Do not choose Map versus IdentityMap based only on familiarity or presumed performance.

  1. Treat null as an actual stored value; missing lookup is an Error.
  2. Remember that canonical true and false are the only semantic Booleans.
  3. Use === for non-overridable semantic identity.
  4. Use == for customizable semantic equality.
  5. Keep identity, equality, and hashes conceptually separate.
  6. Remember the closed value-identity set: Numbers, Strings, true, false, and null.
  7. Do not infer value identity from immutability, freezing, structural equality, or host interning.
  8. Read object[index] as object.at(index), never as dynamic slot lookup.
  9. Remember that Array(3) creates one element containing 3, not three empty positions.
  10. Use Map when keys follow equality and IdentityMap when keys follow identity.
  11. Distinguish an absent Map key from a present key whose value is null.
  12. Treat Set/IdentitySet and Array algorithms as Standard Library abstractions built on Core mechanisms rather than new Core runtime families.

The current tutorial material includes direct examples for keyed collections:

The current Standard Library collection sources are also ordinary Protos and are useful as larger examples:

These files demonstrate current runnable behavior. The specification remains the normative authority.

For exact behavior, consult:

Those documents define the language. This chapter supplies a programmer-facing model for choosing the right identity, equality, and collection semantics.