149. Values in Rust
TypeScript let JavaScript remember what kind of thing each value was. In Rust you write the tag down yourself, as an enum, and then you can measure exactly what a Drip value costs: 16 bytes.
- Read drip-rs’s Value enum and explain why it can be Copy.
- Measure a Value with size_of and explain where its 16 bytes go.
- Explain why printing a Value needs the heap.
- Make Rust print numbers the way JavaScript does.
A type the engine kept for you
In chapter 49 the TypeScript VM’s value type took one line:
type Value = null | boolean | number | Obj;
That works because JavaScript already tags every value. When the VM
asks typeof v === 'number', the engine answers from a tag it keeps
behind the scenes. Rust keeps no hidden tags. If a Drip value can be one
of several kinds, you have to say so in the type, and the type you need
is an enum.
Think of a row of identical lockers. Every locker is the same size, so they line up neatly on the stack. A label on the door says what’s inside: nothing, a yes/no card, a number, or a ticket for a parcel kept in the back room. Big things never go in the locker. Only their tickets do.
The enum
Here is Value, adapted from src/value.rs, with three of the object
variants left out:
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Value {
Nil,
Bool(bool),
Number(f64),
String(StrRef),
Function(Gc<ObjFunction>),
Closure(Gc<ObjClosure>),
Instance(Gc<ObjInstance>),
}
Each variant is a tag, and some carry a payload. The objects themselves
are not inside. A Gc<T> is a typed index (chapter 138): a u32 saying
which slot of the heap’s arena holds the object. A StrRef is a string’s
handle plus its hash. Chapter 153 builds both.
That choice is what ownership forced. Suppose the variant were
String(String). Then a Value would own its text, and a string held
in a global and copied onto the stack would need two owners. You’d be
cloning text on every push, or reaching for Rc everywhere. A handle
owns nothing, so copying one is safe. Every payload here is Copy, so
Value can derive Copy too, and push, pop and peek just copy
bytes.
A value enum gives a dynamically typed language’s values a Rust
type. There is one variant per kind of value. Small values (nil,
booleans, numbers) are stored right in the variant, and heap objects
are stored as handles. If every payload is Copy, the enum can be
Copy, so values move around the VM without being moved or cloned.
Sixteen bytes
Chapter 139 measured it: size_of::<Value>() is 16, with alignment 8,
and Option<Value> is 16 too. With the enum in front of you, you can
see why. The largest payloads are 8 bytes: an f64, or a StrRef. The
tag needs at least one byte more, so 9. An f64 must sit at an address
that is a multiple of 8, and a type’s size is always a multiple of its
alignment[2]. Nine rounds up to 16. Step
through where rustc put each payload:
The layout came from rustc’s -Zprint-type-sizes flag. That flag is
nightly-only, and I unlocked it on stable with RUSTC_BOOTSTRAP=1. It
reports rustc’s choice, not a promise: the Rust Reference lets the
compiler store an enum’s tag however it likes[1].
The layout also shows the two facts chapter 139 could only state. The
hash inside StrRef lands in bytes 8 to 11, which Number needs
anyway. And Option<Value> has no discriminant line of its own in the
report: None is stored as a tag value that no Value variant uses.
Chapter 60’s NaN-boxing would halve this to 8 bytes. Safe Rust could do
it with f64::to_bits, but then every read would need masks and shifts
instead of a match. drip-rs keeps the plain enum. That is a design
choice, not a limit of the language.
use std::mem::{align_of, size_of};
#[derive(Clone, Copy)]
struct Gc(u32);
#[derive(Clone, Copy)]
struct StrRef {
handle: Gc,
hash: u32,
}
#[derive(Clone, Copy)]
enum Value {
Nil,
Bool(bool),
Number(f64),
String(StrRef),
Closure(Gc),
}
fn main() {
let s = size_of::<Value>();
let a = align_of::<Value>();
println!("Value: {s} B, align {a}");
let o = size_of::<Option<Value>>();
println!("Option<Value>: {o}");
// {} and {:e} for a few doubles.
let ns = [0.1 + 0.2, 1e21, 1.5e-7,
f64::INFINITY, f64::NAN, -0.0];
for n in ns {
println!("{n} {n:e}");
}
// Try: delete Number. Size now?
}Equality comes free, printing doesn’t
derive(PartialEq) gives exactly Drip’s ==. Numbers compare as IEEE
doubles, so NaN isn’t equal to itself and 0 equals -0, just as with
the TypeScript VM’s a === b. Handles compare by index, so two objects
are equal only if they are the same object. That’s right for strings too,
because they’re interned (chapter 52). Truthiness is one line:
pub fn is_falsey(self) -> bool {
matches!(self, Value::Nil
| Value::Bool(false))
}
(matches! is a standard macro: true if the value fits the pattern.)
Printing is where handles cost something. TypeScript’s formatValue(v)
reads v.chars straight off the object. A Rust Value::String holds only
an index, and the text is in the heap, so printing needs the heap too:
value.display(&heap) returns a small struct holding both, and that
struct implements Display. Its lifetime ties it to the borrowed heap
(chapter 133).
Numbers that print like JavaScript
Drip prints numbers as JavaScript’s String(n) does, except that the
TypeScript VM prints negative zero as -0 where String(-0) gives 0.
Rust’s {} disagrees in places. Here it is next to Node 22, both run on
this machine (rustc 1.97):
value Rust {} String(n) in Node
0.1+0.2 0.30000000000000004 (same)
100 100 100
1e21 1000000000000000000000
1e+21
1.5e-7 0.00000015
1.5e-7
∞ inf Infinity
NaN NaN NaN
-0 -0 0
The digits always agree. Rust’s float formatting uses the shortest
digits that read back as the same double[4],
and so does JavaScript[3]. Only the layout differs.
ECMAScript writes plain decimals while the decimal point’s position is
between −5 and 21, and exponent notation with an explicit sign outside
that range. So format_number handles NaN, the infinities and zero
first (keeping the sign of -0), takes the digits and exponent from
Rust’s {:e}, and lays them out by JavaScript’s rule. The middle of it,
adapted:
// "1.5e-7" -> digits "15", point -6
let sci = format!("{:e}", n.abs());
let (m, e) = sci.split_once('e')
.expect("{:e} has an 'e'");
let digits: String = m.chars()
.filter(|&c| c != '.').collect();
let exp: i32 = e.parse().expect("int");
let point = exp + 1;
// then integer, d.ddd, 0.000ddd or
// d.ddde±x, by where point falls
How do you test a function like this? value.rs keeps a table of 42
doubles and what the TypeScript VM printed for each under Node 22. Each
double is written as its raw bits, such as 0x3fd3333333333334 for
0.1 + 0.2, and turned back into a number with f64::from_bits. A
decimal literal would first pass through Rust’s float parser, so the
test would quietly depend on a second piece of float conversion code.
Bits name the exact double that Node was given.
Your turn
Write a cut-down drip-rs Value: an enum with the variants Nil, Bool(bool), Number(f64) and Str(StrRef), where StrRef (given) is a string's handle and hash. It must be Copy, comparable with ==, and printable with {:?} (the tests use all three). Then write is_falsey: Drip's rule is that only nil and false are falsey. One test checks that your Value is 16 bytes.
#[derive(Clone, Copy, PartialEq, Debug)]
struct StrRef {
handle: u32,
hash: u32,
}
enum Value {
Nil,
}
impl Value {
fn is_falsey(self) -> bool {
todo!()
}
}Write js_num(n), returning the text JavaScript's String(n) gives, except that negative zero is "-0", as in the TypeScript VM. Use Rust's own formatting for the digits:
- NaN is
"NaN"; the infinities are"Infinity"and"-Infinity". - Zero, and every
nwhose size |n| is at least 1e-6 and below 1e21, print exactly as Rust's{}prints them. - Everything else prints as Rust's
{:e}does, except that a non-negative exponent gets a+:1e21becomes1e+21.
fn js_num(n: f64) -> String {
format!("{}", n)
}What you learned
- A value enum gives Drip’s values a Rust type: one variant per kind,
with small values inline and heap objects as
Gchandles. - Handles own nothing, so
Valueis Copy, and the stack can copy values freely. Valueis 16 bytes: an 8-byte payload, a tag, and padding up to thef64’s alignment. The hash inStrRefrides in space that was there anyway.- Derived
PartialEqis Drip’s==. Printing needs the heap, because a handle is only an index. - Rust and JavaScript print the same shortest digits, laid out
differently.
format_numberuses{:e}and applies JavaScript’s thresholds.
Check your understanding
0/6- 1.
Why do drip-rs values hold
Gchandles for objects, instead of the objects themselves? - 2.
Which of these explains the 16 bytes of a drip-rs
Valueon x86-64? - 3.
What does
size_of::<Option<Value>>()return in drip-rs (rustc 1.97, x86-64)? - 4.
What does
format!("{}", 1e21)produce in Rust? - 5.
Why does
value.display(&heap)take the heap? - 6.
In drip-rs,
ValuederivesPartialEq. Which of these istrue?
References
- [1] The Rust Reference contributors. Enumerations. The Rust Reference. doc.rust-lang.org/reference/items/enumerations.html (checked 2026-10-31)
- [2] The Rust Reference contributors. Type layout. The Rust Reference. doc.rust-lang.org/reference/type-layout.html (checked 2026-10-31)
- [3] Ecma International, Technical Committee 39 (TC39). ECMAScript® Language Specification (ECMA-262), current draft. Ecma International; living draft at tc39.es/ecma262, source at github.com/tc39/ecma262. tc39.es/ecma262/ (checked 2026-10-31)
- [4] The Rust Project Developers. core::num::imp::flt2dec (float-to-decimal conversion) and core::fmt::float. Rust standard library source, rust-lang/rust repository on GitHub. github.com/rust-lang/rust/blob/master/library/core/src/num/imp/flt2dec/mod.rs (checked 2026-10-31)