Lab 4: Values and Type Errors (Milestone 2)
Background
You will replace the two-variant placeholder Value with the real one — nil, booleans, integers,
floats, and strings — and implement equality, ordering, concatenation, length, truthiness, and the
type errors that arise when an operator meets an operand it cannot use.
This lab produces ADR-004 (value representation) and ADR-005 (the integer/float split), and it is where "dynamic typing" stops being a phrase and becomes a table of decisions you had to make by hand.
Why This Lab Matters
Valueis the most-copied type in the runtime. Its size shows up in the VM's stack, in every table, in every argument. You will measure it in Section 7; you decide it here.- Type errors are where a scripting language earns or loses trust.
attempt to multiply a string by a number, with a caret under the string, is the difference between a five-second fix and a fifteen-minute hunt. - Two of Lua's comparison rules are traps — exact integer/float equality, and locale-dependent string ordering — and both are places where the obvious implementation is wrong. One of them Ember fixes deliberately.
Prerequisites
- Labs 1–3 complete.
- Read Value Representation now. It is filed under Section 2 because it is the foundation of the reference interpreter, but it is the concept chapter for this lab. Read at least its Concepts 1 and 2 before Step 1.
Predict First
size_of::<Value>()for the enum in Step 1. Why is it not 9?- Is
1 == 1.0true? Is"1" == 1? - Is
0/0 == 0/0? math.maxinteger + 0.0 == math.maxinteger— true or false? (This is the hard one.)- Are
0and""truthy? #"héllo"— 5 or 6?"10" + 5in Lua? In Ember?"a" < "b"is easy. Is"a" < "B"? What decides?
Step 1: The Real Value
Concept. A tagged union sized for the machine word.
#![allow(unused)] fn main() { // src/value.rs /// A runtime value. 16 bytes, `Copy`, exhaustively matched. /// /// The heap variants hold an 8-byte HANDLE (index + generation) into the heap's /// slot table, not a pointer and not the object. Copying a Value copies the /// handle; two Values with the same handle are the SAME object. /// See ADR-004 and ADR-006. #[derive(Copy, Clone, Debug)] pub enum Value { Nil, Boolean(bool), Integer(i64), Float(f64), Str(GcRef<EmberStr>), // Lab 16 makes this real; Lab 4 may use a placeholder // Table(GcRef<Table>), // Lab 13 // Closure(GcRef<Closure>), // Lab 14 // UserData(GcRef<UserData>),// Lab 20 } #[test] fn value_is_two_words() { // If this ever changes, it is a decision, not an accident. §7 measures what // the size costs; this test makes sure nobody changes it silently. assert_eq!(std::mem::size_of::<Value>(), 16); assert!(std::mem::size_of::<GcRef<EmberStr>>() <= 8); } }
Note: Until Lab 16 builds the heap, use a stand-in for strings —
Str(std::rc::Rc<str>)is fine and keepsValueClonebut notCopy. Write thesize_oftest anyway, against whatever it is today, and put aTODO(lab-16)next to it. The point of the test is to make the number visible, and a number you have to update deliberately is exactly what you want.
Why 16 and not 9. The discriminant needs a byte, f64/i64 need 8, and alignment rounds the
whole thing to 16. Rust's niche optimization cannot help: f64 and i64 use every bit pattern, so
there is no spare niche to hide a tag in. That is precisely the problem NaN boxing solves, by
noticing that f64 has 2^52-ish unused NaN bit patterns and hiding the tag and a pointer in
there. Ember does not do it. Write ADR-004 now, with option B (NaN boxing) recorded honestly as
"8 bytes, requires unsafe, deferred to capstone project 2 where it will be benchmarked".
Checkpoint question. Why does Copy matter for a value type in a VM? What would Clone-only
force the dispatch loop to do?
Step 2: Truthiness
#![allow(unused)] fn main() { impl Value { /// Lua 5.4 §3.3.4: ONLY nil and false are false. Everything else — including /// 0, 0.0, "", and NaN — is true. pub fn is_truthy(&self) -> bool { !matches!(self, Value::Nil | Value::Boolean(false)) } } }
Three lines, and it is one of the most consequential decisions in a dynamic language. Compare:
| Language | Falsy values |
|---|---|
| Lua, Ember | nil, false |
| Python | None, False, 0, 0.0, "", [], {}, set(), and anything with __bool__/__len__ |
| JavaScript | undefined, null, false, 0, -0, 0n, "", NaN |
| Ruby | nil, false |
Lua's rule is the smallest and the least surprising, and it means if x then tests presence, not
non-emptiness. Test it explicitly — this is the kind of rule people assume rather than check:
#![allow(unused)] fn main() { #[test] fn only_nil_and_false_are_falsy() { for src in ["0", "0.0", "\"\"", "0/0"] { assert_eq!(eval(&format!("return not {src}")), Value::Boolean(false), "{src} must be truthy in Ember (Lua 5.4 §3.3.4)"); } for src in ["nil", "false"] { assert_eq!(eval(&format!("return not {src}")), Value::Boolean(true)); } } }
Step 3: Equality — Never Coerce
#![allow(unused)] fn main() { /// Lua 5.4 §3.4.4: values of DIFFERENT TYPES are never equal, except that /// integers and floats are both "number" and compare by mathematical value. pub fn raw_eq(a: Value, b: Value) -> bool { use Value::*; match (a, b) { (Nil, Nil) => true, (Boolean(x), Boolean(y)) => x == y, (Integer(x), Integer(y)) => x == y, (Float(x), Float(y)) => x == y, // NaN != NaN falls out of IEEE-754 (Integer(x), Float(y)) => int_eq_float(x, y), (Float(x), Integer(y)) => int_eq_float(y, x), (Str(x), Str(y)) => str_eq(x, y), _ => false, // "1" == 1 is FALSE } } }
int_eq_float is the trap. The obvious implementation is x as f64 == y, and it is wrong:
lua -e 'print(math.maxinteger + 0.0 == math.maxinteger)' # false
lua -e 'print(math.maxinteger + 0.0)' # 9.2233720368548e+18
i64::MAX is 2^63 − 1. Converting it to f64 rounds up to exactly 2^63, so x as f64 == y
reports equality for a float that is not the integer. Lua gets this right by converting in the other
direction: a float equals an integer only if the float has no fractional part and is exactly
representable as an i64.
#![allow(unused)] fn main() { fn int_eq_float(i: i64, f: f64) -> bool { // Convert the FLOAT to an integer, not the integer to a float. Only exact // conversions count. Lua 5.4 does this in luaV_equalobj / luaV_tointegerns. f.floor() == f && f >= -(2f64.powi(63)) && f < 2f64.powi(63) && (f as i64) == i } }
Write the test with i64::MAX. Then predict, and check against lua, what 2^53 and 2^53 + 1
do — that is where f64 stops being able to represent consecutive integers, and it is a real bug
source in any system that moves numbers between a script and a database.
Warning: Ordering (
<,<=) has the same hazard and needs the same care.1 < 1e300is easy;math.maxinteger < 2.0^63is not. Lua'sLTnuminlvm.chandles it in four cases; read it (rg -n 'LTnum|LEnum|luai_numlt' lvm.c) after you have tried to write it yourself.
Step 4: Ordering, and a Deliberate Divergence
#![allow(unused)] fn main() { fn lt(a: Value, b: Value, span: Span) -> Result<bool> { use Value::*; Ok(match (a, b) { (Integer(x), Integer(y)) => x < y, (Float(x), Float(y)) => x < y, (Integer(x), Float(y)) => int_lt_float(x, y), (Float(x), Integer(y)) => float_lt_int(x, y), // BYTE-WISE comparison. Lua uses strcoll(), which is LOCALE-DEPENDENT. // See the note below: this is a deliberate divergence. (ADR-009) (Str(x), Str(y)) => bytes(x) < bytes(y), (a, b) => return Err(type_error_cmp(a, b, span)), }) } }
Note — a real determinism bug in a real language. Lua 5.4's
l_strcmp(inlvm.c) callsstrcoll, so string ordering depends on the process locale.table.sorton strings can therefore produce different orders on two machines running the same program. Verify it:LC_ALL=C lua -e 't={"a","B"}; table.sort(t); print(t[1],t[2])' LC_ALL=en_US.UTF-8 lua -e 't={"a","B"}; table.sort(t); print(t[1],t[2])'If your system has both locales installed, those print different results. Ember compares bytes, always, because determinism is a product requirement and a ranking policy that reorders itself based on an environment variable is not a policy. Write ADR-009 and the entry in
appendix/lua-differences.md.
This is the single best example in Section 1 of why "just copy Lua" is not a design method. Lua's choice is defensible for its audience — text processing for humans in a locale. Ember's audience is a service that must produce the same ranking on every replica. Same mechanism, different requirement, different answer, written down.
Step 5: Concatenation and Length
#![allow(unused)] fn main() { // `..` — Lua 5.4 §3.4.6: strings and NUMBERS concatenate; everything else errors. BinOp::Concat => match (l, r) { (Str(_), Str(_)) | (Str(_), Integer(_)) | (Str(_), Float(_)) | (Integer(_), Str(_)) | (Float(_), Str(_)) | (Integer(_), Integer(_)) | (Integer(_), Float(_)) | (Float(_), Integer(_)) | (Float(_), Float(_)) => { Value::Str(self.intern(&format!("{}{}", tostring(l), tostring(r)))) } _ => return Err(self.concat_type_error(l, r, lhs.span(), rhs.span())), }, // `#` — Lua 5.4 §3.4.7: BYTE length for strings. UnOp::Len => match v { Value::Str(s) => Value::Integer(self.str_bytes(s).len() as i64), _ => return Err(rt_operand(span, "attempt to get length of", v)), }, }
Two asymmetries worth noticing, because both are deliberate.
- Ember coerces numbers to strings in
..but does not coerce strings to numbers in+. Lua does both. The asymmetry has a reason: every number has exactly one string form, so1 .. ""is total and unsurprising; but"10" + 5requires parsing, which can fail, which means a type-correct-looking program has a run-time parse in it. Section 5's users will writearticle.score + 1wherescorecame from JSON as a string, and Ember should tell them, not guess. Divergence documented; ADR entry written. #counts bytes, not characters.#"héllo"is 6, becauseéis two bytes in UTF-8. That matches Lua (whose strings are byte strings) but surprises everyone. Ember's strings are UTF-8Strings, which makes this a slightly odd hybrid — flag it indocs/limitations.mdand revisit in Lab 16, where you decide whether Ember's strings are text or bytes. Do not decide it now; just be honest that it is undecided.
Step 6: Type Errors That Point at the Right Operand
This is the deliverable that users will actually notice.
#![allow(unused)] fn main() { fn arith_type_error(op: BinOp, l: Value, r: Value, lspan: Span, rspan: Span) -> EmberError { // Blame the FIRST operand that is not a number. Lua does the same, and it // is why `nil * 2` says "a nil value" rather than "these operands". let (bad, span) = if !l.is_number() { (l, lspan) } else { (r, rspan) }; EmberError { kind: ErrorKind::Runtime, message: format!("attempt to {} a {} value", verb(op), bad.type_name()), span: Some(span), traceback: Vec::new(), } } fn verb(op: BinOp) -> &'static str { match op { BinOp::Add => "add", BinOp::Sub => "subtract", BinOp::Mul => "multiply", BinOp::Div | BinOp::IDiv => "divide", BinOp::Mod => "take the modulus of", BinOp::Pow => "exponentiate", BinOp::Concat => "concatenate", _ => "operate on", } } impl Value { /// The name a SCRIPT AUTHOR sees. Integer and Float are both "number", /// because that is the type in the language even though they are distinct /// subtypes in the implementation. Lab 21's `math.type` exposes the subtype. pub fn type_name(&self) -> &'static str { match self { Value::Nil => "nil", Value::Boolean(_) => "boolean", Value::Integer(_) | Value::Float(_) => "number", Value::Str(_) => "string", } } } }
The design decision is blame the first non-number operand, and it requires operand spans —
which the parser preserved because
Binary merges its children's spans rather than using
the operator's. Two labs and one design decision apart. Note that in your journal; it is the
clearest example so far of a cheap early choice paying a visible dividend.
The Trace
$ ember run -e 'return "x" * 2'
<argv>:1:8: error: attempt to multiply a string value
1 │ return "x" * 2
│ ^^^
$ ember run -e 'return 1 + nil'
<argv>:1:12: error: attempt to add a nil value
1 │ return 1 + nil
│ ^^^
$ ember run -e 'return #true'
<argv>:1:9: error: attempt to get length of a boolean value
1 │ return #true
│ ^^^^
$ ember run --types -e 'return 1 == 1.0'
true (boolean)
$ ember run --types -e 'return 3 // 2'
1 (number: integer)
$ ember run --types -e 'return 3 / 2'
1.5 (number: float)
$ ember run --types -e 'return "a" .. 1'
a1 (string)
Note the caret positions: under "x" in the first, under nil in the second. The caret is under
the operand, never under the operator, and never under the whole expression. Check yours.
--types is a debugging flag added in this lab; it prints Value::type_name() plus the numeric
subtype. It exists because type() and math.type() are library functions and there are no
function calls until Lab 7, and you need to see the subtype now.
Expected Output
$ ember run -e 'return 1 == 1.0' # numbers compare by value across subtypes
true
$ ember run -e 'return "1" == 1' # different types are never equal
false
$ ember run -e 'return 0/0 == 0/0' # NaN is not equal to itself
false
$ ember run -e 'return not 0' # 0 is TRUTHY
false
$ ember run -e 'return #"héllo"' # BYTES, not characters
6
$ ember run -e 'return "b" < "a"'
false
$ ember run -e 'return "10" + 5'
<argv>:1:8: error: attempt to add a string value
1 │ return "10" + 5
│ ^^^^
That last one is a deliberate divergence — lua -e 'print("10" + 5)' prints 15. If you have
not yet written it in appendix/lua-differences.md, do it before moving on.
Debugging Steps
size_of::<Value>() is 24
You have a variant carrying something larger than 8 bytes — usually String (24 bytes) instead of a
handle or Rc<str> (16 bytes as a fat pointer). Rc<str> is a fat pointer and will make Value
24 bytes; Rc<String> is thin and gives 16. This is a good moment to feel why handles win.
math.maxinteger + 0.0 == math.maxinteger is true
You compared i as f64 == f. See Step 3.
The caret is under the operator
arith_type_error is being given the Binary node's span rather than the operands'. Thread
lhs.span() and rhs.span() down.
Comparing two strings gives a type error
Your lt match has the (Str, Str) arm after a catch-all, or the arm is missing entirely.
1 .. 2 produces 3
Your parser mapped .. to Add. Check binop() in the parser — DotDot and Plus are adjacent
in the token enum and this is a real copy-paste bug.
"a" < 1 panics
A match arm falling through to unreachable!() instead of returning a type error. Never
unreachable!() on a Value combination; the whole point of the enum is that the combinations are
enumerable and each one is either valid or an error.
Experiment
CLAIM. Automatic string→number coercion converts a type error into a wrong answer, and the failure surfaces far from its cause.
METHOD. Add coercion behind a flag: --lua-coercion makes "10" + 5 produce 15. Write a
20-line "policy" that reads a score from a table of strings, adds a boost, and compares against a
threshold. Introduce one field that is a string when it should be a number. Run with and without
the flag.
PREDICTION. With coercion on, where does the program go wrong, and how far is that from the line containing the bug? Without it?
RESULT. Record both, in docs/learning/05-values.md. Then decide whether ADR-005's divergence
stands. It may not — that is a legitimate outcome, as long as the decision is now evidence-based.
Test
#![allow(unused)] fn main() { #[test] fn equality_never_coerces_across_types() { // Lua 5.4 §3.4.4. Verified: lua -e 'print("1" == 1, 1 == 1.0, nil == false)' assert_eq!(eval("return \"1\" == 1"), Value::Boolean(false)); assert_eq!(eval("return nil == false"), Value::Boolean(false)); assert_eq!(eval("return 1 == 1.0"), Value::Boolean(true)); } #[test] fn integer_float_equality_is_exact_not_lossy() { // i64::MAX rounds UP to 2^63 as an f64, so `i as f64 == f` reports a false // equality. Convert the float to an integer instead. Lua 5.4 luaV_equalobj. assert_eq!(eval("return 9223372036854775807 == 9223372036854775807 + 0.0"), Value::Boolean(false)); assert_eq!(eval("return 9007199254740992 == 9007199254740992.0"), // 2^53 Value::Boolean(true)); } #[test] fn nan_is_not_equal_to_itself() { assert_eq!(eval("return 0/0 == 0/0"), Value::Boolean(false)); assert_eq!(eval("return 0/0 ~= 0/0"), Value::Boolean(true)); } #[test] fn string_ordering_is_bytewise_regardless_of_locale() { // ADR-009: Ember diverges from Lua, which uses strcoll(). This test is the // reason: it must give the same answer under every LC_ALL. assert_eq!(eval("return \"B\" < \"a\""), Value::Boolean(true)); // 'B'=0x42 < 'a'=0x61 } #[test] fn type_errors_blame_the_operand_and_carry_its_span() { let e = try_eval("return 1 + nil").unwrap_err(); assert_eq!(e.kind, ErrorKind::Runtime); assert!(e.message.contains("nil"), "message must name the type: {}", e.message); let sp = e.span.unwrap(); assert_eq!((sp.start, sp.end), (11, 14), "span must cover `nil`, not the whole expression"); } #[test] fn length_is_bytes_not_characters() { assert_eq!(eval("return #\"héllo\""), Value::Integer(6)); } }
Challenge Extensions
- Exact mixed ordering. Implement
int_lt_floatandfloat_lt_intcorrectly for all magnitudes, then property-test against ai128-based reference over randomi64/f64pairs. This is genuinely tricky and it is exactly what Lua'sLTnumdoes. - NaN boxing, measured. Implement
Valueas a NaN-boxedu64behind a feature flag. Get the test suite passing. Benchmarkbenches/dispatch.rs. Report the delta and theunsafeline count. This is capstone project 2 arriving early; if you do it now, do it after Section 7 has a baseline. - A
Valuethat isCopywith anRcstring. It cannot be — explain precisely why in terms ofDrop, and use that to argue for the handle design in Lab 15. - Locale experiment. Install a second locale and demonstrate Lua's
table.sortproducing two different orders. Screenshot it intodocs/learning/05-values.md. It is the most persuasive artifact you will produce in Section 1. - Error message audit. Write a test that evaluates every invalid operator/type pair (7 operators × 5 types × 5 types) and asserts that each error names the operator's verb, the offending type, and has a span inside the offending operand. Roughly 175 cases; the loop is ten lines and it will find three bugs.
Deliverables
-
Valuehas all five variants;size_ofis asserted in a test with a comment explaining the number. - Truthiness, equality, ordering, concatenation, and length implemented and tested.
-
int_eq_floatis exact; thei64::MAXtest passes. - String ordering is byte-wise, with ADR-009 written and the divergence documented.
- Type errors name the operator's verb and the offending type, and their span covers the offending operand.
-
ember run --typesprints the value and its type/subtype. -
appendix/lua-differences.mdcontains: no string→number coercion in arithmetic, byte-wise string ordering, and whatever else Step 4's comparison loop turned up. -
docs/adr/ADR-004-value-representation.mdandADR-005-integer-float-split.mdwritten, each with the rejected options and their real costs. -
tests/golden/has at least 12 programs with expected output. - Milestone 2 complete.
Validation / Self-check
- Why is
Value16 bytes and not 9? What technique makes it 8, and what does that cost? - Give Lua's truthiness rule and contrast it with Python's and JavaScript's. Which is smallest?
- Why is
x as f64 == ythe wrong way to compare an integer with a float? Give the failing value. - Why does Ember compare strings byte-wise, and what specific real-world failure does Lua's choice permit?
- Why does Ember coerce numbers to strings in
..but not strings to numbers in+? State the asymmetry in one sentence. #"héllo"is 6. Is that a bug? What is actually undecided here, and which lab decides it?- In
return 1 + nil, which operand is blamed, why, and which earlier design decision makes the caret land correctly? - Name three places in this lab where the obvious Rust standard-library function gives a subtly non-Lua answer.