39. Functions in Drip
A Drip function is a runtime value with one special power: you can call it. Calling it means a fresh environment, the parameters written into it, and the body run inside.
- Explain what a callable is, and what the interpreter checks before calling one.
- Trace a call of a Drip function, from the callee lookup to the new environment and the body.
- Explain arity, and why Drip is stricter about it than JavaScript.
- Add a native function to the interpreter, and say why a language needs them.
Naming a piece of work
Drip can loop and choose now, but every piece of work has to be written out where it’s used. Chapter 1 started JavaScript with the cure for that, and Drip gets it now:
fn add(a, b) {
print a + b;
}
add(1, 2); // 3
add(3, 4); // 7
Two new pieces of syntax. fn add(a, b) { ... } is a declaration, which
the parser turns into a node holding the name, the parameter tokens and
the body’s statements. add(1, 2) is a call expression. In the Pratt
table, ( is an infix operator with the highest binding power (tied
with .): whatever expression is on its left is the callee. So f(1)(2) parses fine:
it calls whatever f(1) returns.
A function is a value
A function declaration is a recipe card being filed under a name. Filing it cooks nothing. Calling the function is taking the card out, clearing a fresh bit of worktop, laying out the ingredients you were handed, and following the steps on that worktop.
Executing the declaration only files the card:
case 'function':
this.env.define(s.name.lexeme,
new DripFunction(s, this.env));
return;
(The real line passes one more argument, a flag that only matters for
classes.) A DripFunction holds the declaration node and the environment
that was current when it was declared (its closure; chapter 41 is all about
why). Then add is defined like any other variable. Functions are
first-class runtime values in Drip, just as in JavaScript: you can copy
one with let g = add;, and printing one shows <fn add>.
What makes a function special is a single extra ability. In the
companion’s runtime.ts, anything you can call has this shape:
interface DripCallable {
arity(): number;
call(interp: CallContext,
args: Value[]): Value;
}
A callable is a runtime value that can be called. In Drip it has
two methods: arity(), the number of arguments it expects, and
call(interp, args), which does the work and returns a value. Drip
functions are callables; so are native functions, and, in chapter 43,
classes. Numbers, strings, nil and booleans are not.
Evaluating a call
Here is the call case of evaluate, with long lines folded:
case 'call': {
const callee = this.evaluate(e.callee);
const args = e.args.map(
(a) => this.evaluate(a));
if (!isCallable(callee)) {
throw new RuntimeError(e.paren,
'Can only call functions ' +
'and classes.');
}
if (args.length !== callee.arity()) {
throw new RuntimeError(e.paren,
`Expected ${callee.arity()} ` +
`arguments but got ${args.length}.`);
}
return callee.call(this, args);
}
Evaluate the callee, then the arguments, left to right. Then two checks:
is the callee callable at all (123() is not), and does the number of
arguments match? Only then is it called. (e.paren is the ) token,
kept so the error can report a line.)
And here is what calling a DripFunction does, minus a detail for
classes:
call(interp, args) {
const env = new Environment(
this.closure);
this.decl.params.forEach((p, i) =>
env.define(p.lexeme, args[i]));
return interp.executeFunctionBody(
this.decl, env);
}
A brand-new environment, whose parent is the function’s stored
environment. Each parameter is defined in it, holding its argument.
Then the body runs with that environment as the current one, through the
same executeBlock you wrote for blocks. Step through two calls:
let k = 10;fn add(a, b) {let s = a + b;print s + k;}add(1, 2);add(3, 4);
Because every call makes its own environment, two calls never share
parameters or locals. That’s exactly what recursion needs: each call of
a recursive function gets its own n.
Notice too which environment is the parent: the one where the function was declared, not the one it’s called from. That is lexical scope from chapter 6, and you can see it in this example:
fn show() {
print x;
}
let x = "global";
{
let x = "local";
show();
}Arity
A function’s arity is the number of arguments it takes: for a Drip
function, the number of parameters it declares. Drip checks arity on
every call, and a mismatch is a runtime error: Expected 2 arguments but got 3. The callee isn’t run at all.
JavaScript is far more relaxed. It reports a function’s expected count
as f.length[3], but doesn’t enforce it.
A missing argument just makes the parameter undefined, and extra ones
are quietly kept aside[2]. Drip chooses to fail
loudly, which catches a forgotten argument at the call that forgot it.
Native functions
Some things can’t be written in Drip at all. Drip has no way to read the time: no expression or statement asks the outside world anything. So the interpreter supplies one ready-made function, written in TypeScript:
class NativeFunction {
constructor(n, fn) { ... }
arity() { return this.n; }
call(_, args) {
return this.fn(args);
}
}
// in the Interpreter constructor:
this.globals.define('clock',
new NativeFunction(0, () =>
performance.now() / 1000));
clock() returns seconds as a number, from JavaScript’s
performance.now(), a high-resolution timer in
milliseconds[5]. From Drip’s side it’s an
ordinary global holding a callable: clock() passes the same two checks
as add(1, 2). Printing it shows <native fn>.
print clock;
let start = clock();
let sum = 0;
for (let i = 0; i < 100000; i = i + 1) {
sum = sum + i;
}
print sum;
print clock() - start;A native function is a callable implemented in the host language (here TypeScript) instead of in the language being run. Natives are how a language reaches anything outside itself, such as the clock, files or the screen, and how it offers operations that would be too slow or impossible to write in the language itself.
JavaScript’s functions are objects too[4],
with properties like length. Drip’s are TypeScript objects with a
decl and a closure. Look at what one call costs here: an args
array, a new Environment with its own Map, and a define per
parameter, before the body even starts. Chapter 56 shows how the
bytecode VM does a call with no maps at all, by giving each call a
window onto one shared stack. Drip’s design of callables, arity checks
and the clock native follows Crafting Interpreters[1].
Your turn
Write the two halves of a call.
makeNative(n, fn)returns a callable: an object withcallable: true,arity()returningn, andcall(args)returningfn(args).callValue(callee, args)callscalleewith the arrayargs. Ifcalleeis not an object withcallable === true(numbers, strings andnullaren't), throw anErrorwith the messageCan only call functions and classes.Ifargs.lengthdiffers fromcallee.arity(), throwExpected N arguments but got M.without calling it. Otherwise return whatcallee.call(args)returns.
function makeNative(n, fn) {
// your code here
}
function callValue(callee, args) {
// your code here
}
const add = makeNative(2,
(xs) => xs[0] + xs[1]);
console.log(callValue(add, [1, 2]));The loop below calls square, which doesn’t exist yet. Above it, declare a Drip function square(n) that prints n * n, so the program prints 1, 4, 9, 16, 25.
// declare square here
for (let i = 1; i <= 5; i = i + 1) {
square(i);
}What you learned
- Executing
fndoesn’t run anything: it makes a function value holding the declaration and its environment, and defines the name. - A callable is a value with
arity()andcall(). A call expression evaluates the callee and the arguments, checks that the callee is callable and that the arity matches, then calls it. - Calling a Drip function makes a fresh environment whose parent is the function’s own, defines the parameters there, and runs the body in it.
- A native function is implemented in the host language, like
clock. It’s how Drip reaches outside itself.
Next: return, and how the interpreter escapes from deep inside a body.
Check your understanding
0/6- 1.
What does this print?
fn greet(name) { print "hi " + name; } print "start"; - 2.
fn f(a, b, c) {}thenf(1, 2);What happens? - 3.
Which of these is NOT a callable in Drip?
- 4.
When
add(1, 2)is called, where areaandbdefined? - 5.
What does this print?
fn show() { print x; } let x = "global"; { let x = "local"; show(); } - 6.
Why does the interpreter provide
clockas a native function?
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] MDN contributors. Functions (JavaScript Guide). MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Functions (checked 2026-10-31)
- [3] MDN contributors. Function: length. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length (checked 2026-10-31)
- [4] MDN contributors. Function. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function (checked 2026-10-31)
- [5] MDN contributors. Performance: now() method. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/API/Performance/now (checked 2026-10-31)