Skip to content

Equality Model

Janus distinguishes four kinds of equality that most languages conflate. Each axis answers a different question; knowing one answer doesn’t determine the others.

Three deserve syntax. One deserves fear.

AxisQuestionSyntaxLowers toProfile
1Same meaning?==Eq.eq(a, b) (trait)default
2Same object?isidentity primitivedefault
3Same sequence?eqlstd.mem.eql(a, b)default
4Same bits?(no syntax)std.mem.raw.eql(...):sovereign
let a: []const u8 = "hello"
let b: []const u8 = "hello"
let same = a
a == b // semantic equality (depends on Eq impl; reference for slices pre-Phase-4)
a eql b // sequence equality — TRUE (same bytes)
a is b // identity — FALSE (distinct bindings)
a is same // identity — TRUE (same binding)

For primitives:

let x: i32 = 42
let y: i32 = 42
x == y // ✅ true
x eql y // ❌ E1247 — primitives are not sequences
x is y // ❌ E1249 — primitives have no identity

The equality you want 95% of the time. For user types, the Eq trait implementation defines what “equal” means. For primitives, value equality. For collections, typically element-wise semantic equality.

Complexity: implementation-defined. O(1) for primitives, O(n) for collections, custom for user types.

Available in all profiles.

True if a and b are the same object — same allocation, same handle, same binding. Always O(1).

Mirrors Python’s is, Java’s == for objects, Rust’s Arc::ptr_eq.

Identity gate (E1249): Primitives (integers, bools, floats) are rejected. They don’t have meaningful identity — two bindings of the same integer are bitwise-identical, so is would duplicate ==.

let f1 = File.open("/tmp/x")
let f2 = File.open("/tmp/x")
f1 is f2 // FALSE — distinct file handles
f1 == f2 // depends on File's Eq impl (probably path equality)

Available in all profiles.

Element-wise equality for sequence types (slices, arrays, byte strings). Always O(n) with early exits. Lowers to std.mem.eql(a, b).

For []u8 the elements are bytes; for []u32 they’re words. The function compares element-by-element, NOT raw representation bytes (padding, capacity, vtables are not involved because slices have none).

Sequence gate (E1247): Non-sequence types are rejected. Vec[u8] is a struct that contains a sequence; asking eql on it directly would compare struct bytes (pointer + len + capacity), which is meaningless. The compiler tells you to use == (semantic) or extract the underlying slice (v.data eql v.data).

let v1 = Vec[u8].init(allocator, [_]u8{1, 2, 3})
let v2 = Vec[u8].init(allocator, [_]u8{1, 2, 3})
v1 eql v2 // ❌ E1247 — Vec is not a sequence type
v1 == v2 // ✅ semantic Vec equality (via Eq trait)
v1.data eql v2.data // ✅ byte equality on underlying slices

Available in all profiles.

std.mem.raw.eql — Representation equality (Axis 4)

Section titled “std.mem.raw.eql — Representation equality (Axis 4)”

Raw byte comparison. Includes everything: padding bytes, NaN payloads, vtables, capacity metadata. Almost never what people mean.

// In :sovereign profile only:
use std.mem.raw as raw
if raw.eql(&buf_a[0], &buf_b[0], 64) do ... end

No operator syntax. Forcing the programmer to write the full function name and provide explicit raw pointers plus a byte count makes the danger visible. The programmer who writes raw.eql(...) has to mean it.

Profile-gated to :sovereign. Use cases:

  • Forensic / debugging tools that inspect raw memory
  • Hash table key comparison where keys have been canonicalized
  • Cryptographic tag verification (with explicit context)
  • Constant-time comparison primitives (built on top with care)

The four axes answer different questions for most types — that’s the whole point. But for a struct declared @layout(wire) and proven WireSafe (see Representation & Layout), all four axes agree:

  • The Canonical Representation Principle guarantees exactly one valid byte representation per value (no padding ambiguity, no inactive-variant scratch, no NaN-payload divergence).
  • Therefore byte equality ⟺ semantic equality for T: WireSafe.
  • The compiler MAY lower == on WireSafe types directly to memcmp.

Outside WireSafe, the four-axis distinction still matters and the rules above still apply. WireSafe collapses the axes by making representation itself canonical.

Languages that conflate these axes produce bugs:

  • Padding bytes: Two Foo { x: u8, y: u64 } structs with identical fields may have different bytes due to uninitialized padding. Semantic equality (==) is true; representation equality is false. Both are correct — they answer different questions.

  • NaN payloads: f32.nan == f32.nan is false per IEEE 754. But two NaN values may have identical bit patterns. Semantic equality and representation equality diverge. This is the point of having four axes, not a bug in the model.

  • Vec identity vs equality: Two Vec[u8] allocated separately with the same contents are semantically equal (== is true) but not identical (is is false — different allocations). A third question — do their underlying byte slices match? — is yet another operation (v.data eql v.data).

The four-axis model is additive. Existing code that doesn’t use eql or is continues to work unchanged.

If you wrote…Consider…
if slice_a == slice_b expecting byte equalityif slice_a eql slice_b
if ptr_a == ptr_b for pointer identityif ptr_a is ptr_b
16-line byte-by-byte comparison loopif a eql b
std.mem.raw.compare_bytes(...) == 0if a eql b
CodeMeaningFix
E1247eql on non-sequence operandsUse ==, or extract the underlying slice
E1249is on primitive value typesUse ==