DEV Community

Cover image for Tsonnet #36 — Call me maybe, but make it typed, part 2
Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at bitmaybewise.substack.com

Tsonnet #36 — Call me maybe, but make it typed, part 2

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 basic function support — positional parameters, multiline bodies, and type inference that resolves at call time:

Now let's make functions a little more forgiving. In this post, we'll implement default function arguments.

What's on the menu?

// samples/functions/default_args.jsonnet
local my_function(x, y=10) = x + y;
my_function(2)
Enter fullscreen mode Exit fullscreen mode

The call passes only one argument. y falls back to its default value of 10, so the result is 12.

Teaching the parser about optionals

The parser needs a small adjustment to accept an optional default expression per parameter. Previously, each parameter was just an ID. Now it can optionally include = <expr>:

diff --git a/lib/parser.mly b/lib/parser.mly
index 102d202..290b280 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -182,9 +182,14 @@ single_var:
   | LOCAL; var_expr = var { Local (with_pos $startpos $endpos, [var_expr]) }
   ;

+fundef_param:
+  | name = ID { (name, None) }
+  | name = ID; ASSIGN; default = assignable_expr { (name, Some default) }
+  ;
+
 fundef:
   | fname = ID;
-    LEFT_PAREN; params = separated_nonempty_list(COMMA, ID); RIGHT_PAREN;
+    LEFT_PAREN; params = separated_nonempty_list(COMMA, fundef_param); RIGHT_PAREN;
     ASSIGN;
     body = fundef_body { (fname, params, body) }
   ;
Enter fullscreen mode Exit fullscreen mode

And the AST type updates to carry the optional default alongside the parameter name:

diff --git a/lib/ast.ml b/lib/ast.ml
index 106f37c..f4c1472 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -90,7 +90,7 @@ type expr =
   | Local of position * (string * expr) list
   | Seq of expr list
   | IndexedExpr of position * string * expr
-  | FunctionDef of position * (string * string list * expr)
+  | FunctionDef of position * (string * (string * expr option) list * expr)
   | FunctionCall of position * string * expr list

 and object_entry =
Enter fullscreen mode Exit fullscreen mode

Each parameter goes from a plain string to a string * expr optionNone for required, Some expr for optional with a default.

Filling in the blanks

The arity check is a bit more involved now, since we have to distinguish between required and optional parameters:

diff --git a/lib/interpreter.ml b/lib/interpreter.ml
index d512c0f..6ad259e 100644
--- a/lib/interpreter.ml
+++ b/lib/interpreter.ml
@@ -422,16 +422,30 @@ and interpret_function_def env (pos, (fname, params, body)) =
 and interpret_function_call env (pos, fname, call_params) =
   match Env.find_opt fname env with
   | Some (FunctionDef (pos, (_, def_params, body))) ->
-    if List.compare_lengths call_params def_params <> 0
+    let num_call = List.length call_params in
+    let num_def = List.length def_params in
+    let num_required =
+      List.length (
+        List.filter (fun (_, default) -> Option.is_none default) def_params
+      )
+    in
+    if num_call < num_required || num_call > num_def
     then Error.error_at pos "wrong number of param(s)"
     else
-      let bindings =
-        List.mapi
-          (fun index value ->
-            let param_name = List.nth def_params index in
-            (param_name, value)
+      let* bindings =
+        List.fold_left
+          (fun acc (index, (param_name, default)) ->
+            let* bindings = acc in
+            if index < num_call
+            then
+              ok (bindings @ [(param_name, List.nth call_params index)])
+            else
+              match default with
+              | Some default_expr -> ok (bindings @ [(param_name, default_expr)])
+              | None -> Error.error_at pos "wrong number of param(s)"
           )
-          call_params
+          (ok [])
+          (List.mapi (fun i p -> (i, p)) def_params)
       in
       let env' = List.fold_left
         (fun env (k, v) -> Env.add_local k v env)
Enter fullscreen mode Exit fullscreen mode

The key change: instead of checking for an exact match, we now compute both the total number of defined parameters (num_def) and the number of required ones (num_required). The call is valid if the number of supplied arguments falls anywhere in the range [num_required, num_def]. For arguments not supplied by the caller, we fall back to the default expression.

Knowing the type before the call

Default arguments give the type checker a nice bonus: when a parameter has a default value, we can infer its type right at declaration time instead of waiting for the first call.

diff --git a/lib/type.ml b/lib/type.ml
index bdbf357..05bc187 100644
--- a/lib/type.ml
+++ b/lib/type.ml
@@ -475,9 +475,21 @@ and translate_bin_op venv pos op e1 e2 =
   | _ -> Error.error_at pos Error.Msg.invalid_binary_op

 and translate_function_def venv (pos, (fun_name, params, body)) =
-  (* As of now, we don't know the input types at declaration *)
-  let params_typed = List.map (fun name -> (name, Tunresolved)) params in
-  (* We also don't know the result type *)
+  (* For params with defaults, we can infer the type from the default expression;
+     params without defaults remain Tunresolved until the first call *)
+  let* params_typed = List.fold_left
+    (fun acc (name, default) ->
+      let* params' = acc in
+      match default with
+      | Some default_expr ->
+        let* (_, default_ty) = translate venv default_expr in
+        ok (params' @ [(name, default_ty)])
+      | None ->
+        ok (params' @ [(name, Tunresolved)])
+    )
+    (ok [])
+    params
+  in
   let fun_def = TfunctionDef (params_typed, body, Tunresolved) in
   (* So, function declaration will have an unresolved type definition,
      that only later it will be translated: before function call translation!
Enter fullscreen mode Exit fullscreen mode

Parameters without defaults stay Tunresolved and get resolved at the call site, same as before. Parameters with defaults get their type resolved immediately. In our example, y=10 means y is typed as Number from the moment the function is declared.

The arity check at the call site follows the same logic as the interpreter — allow fewer arguments than the total, as long as the mandatory ones are all there:

@@ -490,35 +502,39 @@ and translate_function_call venv (pos, fname, call_params) =
   (* 1. retrieve TfunctionDef from venv *)
   match Env.find_opt fname venv with
   | Some (TfunctionDef (def_params, body_expr, return_type)) ->
-    (* check arity *)
-    if List.compare_lengths call_params def_params <> 0
+    (* check arity: allow fewer args if defaults exist *)
+    let num_call = List.length call_params in
+    let num_def = List.length def_params in
+    if num_call > num_def
     then
       Error.error_at pos
-        (Error.Msg.type_wrong_number_of_params
-          (List.length def_params) (List.length call_params))
+        (Error.Msg.type_wrong_number_of_params num_def num_call)
     else
       (* 2. type check each positional parameter passed in the function call *)
       let* (venv', resolved_params) =
-        List.fold_left2
-          (fun acc call_param (param_name, def_param_type) ->
+        List.fold_left
+          (fun acc (index, (param_name, def_param_type)) ->
             let* (venv', params') = acc in
-            let* (venv'', call_param_type) = translate venv' call_param in
-            match def_param_type with
-            | Tunresolved ->
-              (* 2a. unresolved: accept and record the concrete type *)
-              ok (venv'', params' @ [(param_name, call_param_type)])
-            | expected ->
-              (* 2b. resolved: type check against the concrete type *)
-              if call_param_type = expected
-              then ok (venv'', params' @ [(param_name, expected)])
-              else Error.error_at pos
-                (Error.Msg.type_mismatch
-                  ~expected:(to_string expected)
-                  ~got:(to_string call_param_type))
+            if index < num_call
+            then
+              let call_param = List.nth call_params index in
+              let* (venv'', call_param_type) = translate venv' call_param in
+              match def_param_type with
+              | Tunresolved ->
+                ok (venv'', params' @ [(param_name, call_param_type)])
+              | expected ->
+                if call_param_type = expected
+                then ok (venv'', params' @ [(param_name, expected)])
+                else Error.error_at pos
+                  (Error.Msg.type_mismatch
+                    ~expected:(to_string expected)
+                    ~got:(to_string call_param_type))
+            else
+              (* default arg — resolve type from the original AST default expression *)
+              ok (venv', params' @ [(param_name, def_param_type)])
           )
           (ok (venv, []))
-          call_params
-          def_params
+          (List.mapi (fun i p -> (i, p)) def_params)
       in
       (* 3. type check return *)
       let body_venv = List.fold_left
Enter fullscreen mode Exit fullscreen mode

When the caller omits an argument that has a default, we skip the type-checking step for that parameter and carry its already-resolved type forward. No extra work needed — the type was inferred when the function was declared.

The moment of truth

diff --git a/test/cram/functions.t b/test/cram/functions.t
index 0ad7e43..fa29868 100644
--- a/test/cram/functions.t
+++ b/test/cram/functions.t
@@ -3,3 +3,6 @@

   $ tsonnet ../../samples/functions/multiline.jsonnet
   [ 6, 7 ]
+
+  $ tsonnet ../../samples/functions/default_args.jsonnet
+  12
Enter fullscreen mode Exit fullscreen mode

my_function(2) with y=10 gives 12. Working as expected.

Conclusion

Default arguments are in. With a surprisingly contained change across parser, AST, interpreter, and type checker, my_function(x, y=10) now works as you'd expect — and the type checker even gets to resolve y's type at declaration time rather than waiting for the first call.

Here is the entire diff.

Next up, functions get a serious upgrade: closures.


Thanks for reading Bit Maybe Wise! Default arguments are optional. Subscribing isn't — but I'd never force you. Subscribe

Photo by Compare Fibre on Unsplash

Top comments (0)