Welcome to the Tsonnet series!
If you’re not following along, check out how it all started in the first post of the series.
In the previous post, we added warnings for unused variables and untouched bindings:
Now let’s get to the most exciting feature in any programming language: functions!
What are we even working with here?
A simple inline function with a positional parameter:
// samples/functions/positional_params.jsonnet
local my_function(x) = x * 2;
my_function(3)And a function with a multiline body:
// samples/functions/multiline.jsonnet
local simple_function(x, y) = x + y;
local multiline_function(x) =
local temp = x * 2;
[temp, temp + 1];
multiline_function(
simple_function(1, 2)
)Parsing (a.k.a. making sense of text)
We need two new AST variants -- one for function definition, one for function call:
And the parser rules to match:
fundef_body deserves a note: it lets us nest local bindings inside the function body, which is what makes multiline_function work. A function body is either a plain expression or a local binding followed by a semicolon and the rest of the body, recursively.
Interpreting: where things actually happen
While working on the interpreter, I noticed that evaluating_fields was a misleading name — interpret_ident handles not just object fields, but regular local bindings too. Renamed it to evaluating_bindings:
I also needed a helper to run a function in a fresh evaluation environment, then restore the previous state afterwards:
This matters for function calls: we don’t want the caller’s evaluation state leaking into the function body.
The new pattern-matching cases route to their respective handlers:
Function definition just registers the function in the environment:
Function call is where things get interesting:
Step by step:
Check that the number of arguments matches the definition. I could skip this in the interpreter and rely on the type checker alone, but I’m keeping it in both for now -- type checking can be bypassed during development for faster iteration.
Pair up each call argument with its corresponding parameter name.
Add those bindings to the environment.
Interpret the function body in that environment, being careful to return the caller’s environment, not the one modified inside the body.
Making sure this isn’t completely illegal
We don’t have type annotations yet, so the type checker needs to infer parameter types and the return type. Here’s the motivating example -- the first call is fine, the second should error:
// samples/semantics/invalid_function_call_type.jsonnet
local my_function(x) = x * 2;
my_function(3) && my_function("oops")We need three new type variants:
Tunresolved is the key one. At declaration time, we don’t know what types the parameters will have -- that only becomes clear at the call site. So we use Tunresolved as a placeholder and fill it in later.
Two new error messages to go with it:
The new cases in translate forward to specialised functions, same pattern as always:
At declaration time, all types are Tunresolved:
At call time, Tunresolved gets replaced with concrete types -- and subsequent calls are checked against those:
One limitation worth mentioning: the first call wins. If the first call passes a value with the wrong type for the intended use, the type checker won’t catch it -- that’s what type annotations are for. They’re coming once Tsonnet reaches a reasonable level of Jsonnet compliance.
Proof that it (mostly) works
Ta-da!
$ dune exec -- tsonnet samples/functions/positional_params.jsonnet
6
$ dune exec -- tsonnet samples/functions/multiline.jsonnet
[ 6, 7 ]
$ dune exec -- tsonnet samples/semantics/invalid_function_call_type.jsonnet
ERROR: samples/semantics/invalid_function_call_type.jsonnet:2:18 Expected type Number, got String
2: my_function(3) && my_function("oops")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^The error could be more precise -- ideally it would point at the offending argument rather than the entire expression. That’s a detail I’ll get to later; not something that adds much right now.
The cram tests:
Conclusion
Basic functions are in: positional parameters, multiline bodies, arity checks, and type inference that resolves on the first call. Not bad for a first round.
Here is the entire diff.
Next up, we make function calls a little more forgiving — default arguments are coming.
Thanks for reading Bit Maybe Wise! First caller wins the type. Subscribe to lock in yours.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.