49. Representing values
Your VM’s stack has held nothing but numbers. Real Drip programs push nil, booleans and strings onto the same stack, and every instruction has to know which is which.
- Explain why a dynamically typed VM attaches a type tag to every value.
- Describe a tagged value as a tag plus a payload, and draw the four kinds Drip needs.
- Read the companion VM’s Value type and explain how it borrows JavaScript’s own tags.
- Implement equality and truthiness for tagged values, types first.
One stack, many kinds of thing
The stack machine from chapter 47 held numbers, and only numbers. That
made OP_NEGATE easy: pop, flip the sign, push. Now look at what real
Drip programs push:
print -(1 + 2) == nil;
print !true;
print "tea" + "pot";
Numbers, nil, booleans and strings all land in the same stack array.
OP_NEGATE must refuse a string. OP_ADD must add numbers but join
strings. OP_EQUAL must say 1 == "1" is false. An instruction can only
do that if every value on the stack can answer one question: what kind
of thing are you?
Drip is dynamically typed (chapter 26): the compiler doesn’t know the answer ahead of time, so the value itself has to carry it, all the way to runtime.
Tagged values
A cloakroom. Every hook is the same size, and every hook has a small label: “umbrella”, “scarf”, “ticket for the big suitcase in the back”. The attendant reads the label first, then knows how to handle what hangs there. Nobody has to unwrap anything to find out.
You can write that picture down directly. In TypeScript, a discriminated union (chapter 14) is exactly a tag plus a payload:
type Tagged =
| { tag: 'nil' }
| { tag: 'bool'; as: boolean }
| { tag: 'num'; as: number }
| { tag: 'obj'; as: Obj };
The instruction checks the tag, and TypeScript narrows the payload for you:
function negate(v: Tagged): Tagged {
if (v.tag !== 'num') {
throw new Error(
'Operand must be a number.');
}
return { tag: 'num', as: -v.as };
}
This is close to what clox, the C virtual machine in Crafting Interpreters, does: a small struct holding a type field and a C union for the payload[1]. Drip is a dialect of that book’s language, Lox.
A tagged value is a runtime value stored together with a tag that says its type, plus a payload holding the data itself. Every instruction that cares about types reads the tag before it touches the payload. In Drip the tags are nil, boolean, number and object, and an object’s payload is a reference to memory on the heap.
const nil = { tag: 'nil' };
const num = as => ({ tag: 'num', as });
const str = as => ({ tag: 'obj', as });
function negate(v) {
if (v.tag !== 'num') {
return 'Operand must be a number.';
}
return num(-v.as);
}
console.log(negate(num(3)));
console.log(negate(str('a')));
console.log(negate(nil));The tags at work
Here is that first line, print -(1 + 2) == nil;, compiled by the
companion compiler and run on the real stack. Watch the tags, not the
numbers.
OP_CONSTANT 0 '1'OP_CONSTANT 1 '2'OP_ADDOP_NEGATEOP_NILOP_EQUALOP_PRINT
Three rules fell out of that run, and Drip keeps them everywhere:
- Arithmetic checks types.
-"a"is a runtime error,Operand must be a number., and so is1 + nil. - Equality never fails. Values with different tags are simply
unequal.
1 == "1"andnil == falseare both false. - Truthiness looks at tags too. Only
nilandfalseare falsey (chapter 35).0and""are truthy, because their tags aren’t nil or bool.
print nil; print true; print 3 + 0.5; print -0; print 1 == "1"; print nil == false; print !nil; print !0; print -"a";
How the companion VM stores a value
The Tagged union above works, but think about what it costs in a VM
written in JavaScript. Every { tag, as } is a fresh object. So 1 + 2
would allocate a new object to hold 3, and every read of a value would
follow a reference to reach its payload. That is the HeapNumber problem
from chapter 22, built by hand, for every value in the program.
The companion VM doesn’t write a tag at all. Here is its whole value
type, from vm/object.ts:
type Value =
null | boolean | number | Obj;
null stands for nil, and booleans and numbers are plain JavaScript
values. The tag is still there. JavaScript values already carry their
type, and the engine keeps track of it (chapter 22’s Smi tag bit is one
of V8’s ways[2]). So reading the tag is
just asking JavaScript:
case OP.NEGATE:
if (typeof peek(0) !== 'number') {
runtimeError(
'Operand must be a number.');
}
push(-(pop() as number));
break;
Equality is one line, a === b. For numbers, booleans and null,
JavaScript’s === already compares type and contents. For objects it
compares identity, which is right for every object Drip has, and
chapter 52 makes it right for strings too.
Printing has one surprise. String(-0) gives "0", but Drip prints
negative zero as -0, so the VM checks for it first:
function formatNumber(n: number) {
return Object.is(n, -0)
? '-0' : String(n);
}
A value representation is the memory layout a runtime chooses for its values: where the tag lives, where the payload lives, and how big a slot is. A boxed layout puts each value in its own object behind a reference. An unboxed or immediate layout keeps the value right in the slot. The companion VM uses JavaScript primitives for nil, booleans and numbers, so they stay unboxed as far as the VM is concerned, and only heap objects sit behind a reference.
“Unboxed as far as the VM is concerned” hides a layer. V8 tags every
slot it stores: small integers are Smis, kept in the slot with a tag
bit, and other numbers, like 3.5, can become HeapNumbers on V8’s heap
behind a pointer[3]. So the companion VM’s
values are tagged twice over, once by meaning (its Value type) and once
by the engine underneath. A VM written in C can’t lean on anybody: it
picks its own layout, such as clox’s struct with a type field and a
union[1]. Chapter 60 shows a cleverer layout that
squeezes the tag and the payload into a single 64-bit double, using bit
patterns that IEEE 754 leaves spare.
Your turn
Values are tagged objects: { tag: 'nil' }, { tag: 'bool', as } or { tag: 'num', as }. Write isFalsey(v), true only for nil and for the boolean false, and valuesEqual(a, b), true when the tags match and the payloads are ===. Two nils are always equal.
const nil = { tag: 'nil' };
const bool = (as) =>
({ tag: 'bool', as });
const num = as => ({ tag: 'num', as });
function isFalsey(v) {
return false;
}
function valuesEqual(a, b) {
return a === b;
}Write format(v) for the same tagged values. nil prints as nil, booleans as true or false, and numbers as JavaScript would, except that negative zero prints as -0.
const nil = { tag: 'nil' };
const bool = (as) =>
({ tag: 'bool', as });
const num = as => ({ tag: 'num', as });
function format(v) {
return String(v.as);
}What you learned
- A dynamically typed VM needs tagged values: a tag saying the type, and a payload with the data. Instructions read the tag first.
- Drip has four tags: nil, boolean, number and object. An object’s payload is a reference to the heap.
- Arithmetic checks tags and can fail. Equality compares tags first and never fails: different types are unequal.
- A value representation is the layout of a value in memory. The
companion VM’s
Valueisnull | boolean | number | Obj, so it reads JavaScript’s own tags withtypeof, andvaluesEqualis===.
Next: the fourth tag. Strings are the first value that can’t fit in a slot, so they move to the heap.
Check your understanding
0/6- 1.
Why must a Drip VM keep a type tag with each value at runtime?
- 2.
What does
print 1 == "1";print on the Drip VM? - 3.
In a tagged value for
nil, what is the payload? - 4.
The companion VM’s value type is
null | boolean | number | Obj. How doesOP_NEGATEcheck for a number? - 5.
Which values are falsey in Drip?
- 6.
Why would wrapping every value in a
{ tag, as }object be slow in a VM written in JavaScript?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
- [2] Igor Sheludko, Santiago Aboy Solanes (2020). Pointer Compression in V8. V8 blog (v8.dev), 30 March 2020. v8.dev/blog/pointer-compression (checked 2026-10-31)
- [3] Benedikt Meurer, Mathias Bynens (2019). The story of a V8 performance cliff in React. V8 blog (v8.dev), 28 August 2019. v8.dev/blog/react-cliff (checked 2026-10-31)