Structured data access

mut config = { server: { host: "localhost", port: 8080 }, features: ["auth", "cache"] }

print(config.server.port)  # 8080
print(config.features[0])  # auth
config.server.port = 9090  # path assignment; root must be mutable

Keys, bare or quoted

A bare key must be an identifier - keywords are allowed, so x.type is fine. A quoted key can be anything, which is what makes HTTP headers and hyphenated JSON expressible inline:

http.response(303, "", { location: "/", "set-cookie": "a=b; HttpOnly" })

{ a: 1 } and { "a": 1 } are the same map. A quoted key is taken verbatim - {expr} does not interpolate in key position, because a map literal's shape is fixed at parse time. ecko fmt canonicalizes to the bare form where the key is a plain identifier.

Access is strict

xs[9]          # index 9 is out of bounds (len 3)
m.key          # map has no key 'key'
s.field        # struct has no field 'field'

The nullable lookup is get:

get(m, "key")       # null on a miss
get(xs, 9)          # null on a miss

Reach for get when absence is expected, and indexing when it is not. That choice is the whole point: a strict read fails at the mistake, while a nullable read that silently returns null propagates the problem into unrelated code. Rule 1 of the error dialect is that only the documented nullable lookups return null.

match tests rather than accesses, so a non-matching pattern is never an error - which makes it the right tool for data of uncertain shape.

Membership with in

xs = [1, 2, 3]
print(2 in xs)  # true
print("ell" in "hello")  # true - substring
print("port" in { port: 80 })  # true - key
print(not (9 in xs))  # true - `not 9 in xs` means the same

item in coll gives the same answer as contains(coll, item): an equal element of a list, a substring of a string or bytes, a key of a map, an Int inside a range. Anything else, like 1 in 5, is an error rather than false.

Defaults with ??

a ?? b is a, unless a is null - then it is b. It is the other half of get: the lookup that may miss, and the value to use when it does.

settings = { level: "debug" }
level = get(settings, "level") ?? "info"  # "debug"
port = get(settings, "port") ?? 8080  # 8080
zero = get({ n: 0 }, "n") ?? 5  # 0

Only null is replaced. 0, false and "" are values, not absence, which is the difference from or - or asks whether something is truthy and answers with a Bool. The right side runs only when it is needed, so get(cache, k) ?? fetch(k) fetches only on a miss.

It binds tighter than comparison and looser than arithmetic - get(m, "n") ?? 0 > 5 compares the defaulted value - and a chain takes the first value that is not null: a ?? b ?? c.

Combining maps

merge(a, b) is a new map with b's entries over a's. The right side wins a shared key, neither input changes, and a nested map is replaced rather than merged into.

defaults = { host: "localhost", port: 8080 }
config = merge(defaults, { port: 9090 })  # { host: "localhost", port: 9090 }

a + b on two maps is an error that names merge: which side should win a shared key is a choice worth writing down.

Slicing

Strings, lists and bytes slice with range syntax. Strings slice by character, so multi-byte UTF-8 is handled correctly:

s = "hello"
s[1..3]  # "el"    exclusive end
s[0..=2]  # "hel"   inclusive end
s[..3]  # "hel"   open start
s[2..]  # "llo"   open end
s[-3..]  # "llo"   negative from the end
s[1..-1]  # "ell"

xs = [1, 2, 3, 4]
xs[1..3]  # [2, 3]

Slices are total where indexing is strict: out-of-range bounds clamp (s[2..99] is "llo", s[7..9] is "") and a reversed range is empty. Slicing takes what is there and never errors.

That asymmetry is deliberate. xs[i] asks for one specific element, and its absence is a mistake. xs[a..b] asks for a region, and "less than you asked for" is a sensible answer.

..= requires an end index: s[0..=] is a parse error, and s[0..] is the open form.

Determinism

Maps print and JSON-encode with sorted keys, and map iteration yields pairs in sorted key order. Output is reproducible without sorting anything yourself.