Viscosity
Review
Part IV · A bytecode virtual machine · Day 13

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.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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.

Four tagged values: nil, a boolean, a number and an object. Each has a tag and a payload; the object's payload points to a string on the heap.TAGPAYLOADHEAPnil(nothing)booltruenum3.5objaddressstring object"hello"Every slot is the same size. Big things live elsewhere.
A value is a tag plus a payload. The four kinds of Drip value. nil needs no payload at all. A boolean or a number fits in the slot. An object is too big for any fixed slot, so its payload is an address: an arrow to the object on the heap.

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.

Definition

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.

Try itTags you can see
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.

Step throughEvery slot knows its type
OP_CONSTANT 0 '1'
OP_CONSTANT 1 '2'
OP_ADD
OP_NEGATE
OP_NIL
OP_EQUAL
OP_PRINT
The value stack, bottom on the left, each slot drawn with its tag and payloadVALUE STACK (bottom → top)num1
OP_CONSTANT copies constant 0 onto the stack. The slot gets a tag, num, as well as the payload 1. From now on, anyone can ask this slot what it holds.
1/7

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 is 1 + nil.
  • Equality never fails. Values with different tags are simply unequal. 1 == "1" and nil == false are both false.
  • Truthiness looks at tags too. Only nil and false are falsey (chapter 35). 0 and "" are truthy, because their tags aren’t nil or bool.
Try itDrip on the VM: tags in action
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);
}
Definition

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.

Under the hood

“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

ExerciseEquality and truthiness, tags first

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;
}
Extra practicePrinting a tagged value

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 Value is null | boolean | number | Obj, so it reads JavaScript’s own tags with typeof, and valuesEqual is ===.

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. 1.

    Why must a Drip VM keep a type tag with each value at runtime?

  2. 2.

    What does print 1 == "1"; print on the Drip VM?

  3. 3.

    In a tagged value for nil, what is the payload?

  4. 4.

    The companion VM’s value type is null | boolean | number | Obj. How does OP_NEGATE check for a number?

  5. 5.

    Which values are falsey in Drip?

  6. 6.

    Why would wrapping every value in a { tag, as } object be slow in a VM written in JavaScript?

References

  1. [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. [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. [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)