DEV Community

Cover image for Tsonnet #42 - If then else (finally)
Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at bitmaybewise.substack.com

Tsonnet #42 - If then else (finally)

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 fixed function calls with zero arguments:

Now conditionals are on the menu. Literally.

The target

This is the Jsonnet tutorial for conditionals:

// samples/tutorials/conditionals.jsonnet

local Mojito(virgin=false, large=false) = {
  // A local next to fields ends with ','.
  local factor = if large then 2 else 1,
  // The ingredients are split into 3 arrays,
  // the middle one is either length 1 or 0.
  ingredients: [
    {
      kind: 'Mint',
      action: 'muddle',
      qty: 6 * factor,
      unit: 'leaves',
    },
  ] + (
    if virgin then [] else [
      { kind: 'Banks', qty: 1.5 * factor },
    ]
  ) + [
    { kind: 'Lime', qty: 0.5 * factor },
    { kind: 'Simple Syrup', qty: 0.5 * factor },
    { kind: 'Soda', qty: 3 * factor },
  ],
  // Returns null if not large.
  garnish: if large then 'Lime wedge',
  served: 'Over crushed ice',
};

{
  Mojito: Mojito(),
  'Virgin Mojito': Mojito(virgin=true),
  'Large Mojito': Mojito(large=true),
}
Enter fullscreen mode Exit fullscreen mode

We'll finish this post having this implemented.

A new variant

Let's add a new expr variant:

diff --git a/lib/ast.ml b/lib/ast.ml
index 1b5724b..35f88a6 100644
--- a/lib/ast.ml
+++ b/lib/ast.ml
@@ -93,6 +93,7 @@ type expr =
   | FunctionDef of position * function_def
   | FunctionCall of position * function_call
   | Closure of position * closure
+  | If of position * expr * expr * expr option

 and object_entry =
   | ObjectField of string * expr
Enter fullscreen mode Exit fullscreen mode

The last field is an option because the else branch isn't always mandatory.

Three new keywords

Nothing complex here. Just some precedence rules to ensure the right evaluation order:

diff --git a/lib/lexer.mll b/lib/lexer.mll
index 4d65f55..3426d02 100644
--- a/lib/lexer.mll
+++ b/lib/lexer.mll
@@ -80,6 +80,9 @@ rule read =
   | "self" { SELF }
   | "$" { TOP_LEVEL_OBJ }
   | "in" { IN }
+  | "if" { IF }
+  | "then" { THEN }
+  | "else" { ELSE }
   | "function" { FUNCTION }
   | id { ID (Lexing.lexeme lexbuf) }
   | _ { raise (SyntaxError ("Unexpected char: " ^ Lexing.lexeme lexbuf)) }

diff --git a/lib/parser.mly b/lib/parser.mly
index 781ec5d..40858f4 100644
--- a/lib/parser.mly
+++ b/lib/parser.mly
@@ -21,6 +21,9 @@
 %token COLON
 %token DOT
 %token SELF TOP_LEVEL_OBJ
+%token IF THEN ELSE
+%nonassoc THEN
+%nonassoc ELSE
 %token PLUS MINUS MULTIPLY DIVIDE MODULO
 %nonassoc FUNCTION
 %left PLUS MINUS
@@ -61,6 +64,7 @@ assignable_expr:
   | op = unary_op; e = assignable_expr { UnaryOp (with_pos $startpos $endpos, op, e) }
   | e = indexed_expr { e }
   | e = obj_field_access { e }
+  | e = conditional { e }
   | e = funcall { e }
   | e = closure { e }
   ;
@@ -241,4 +245,17 @@ closure:
     }
     (* precedence here will transform "function(x) x * x" into "function(x) (x * x)" *)
     %prec FUNCTION
-  ;
+  ;
+
+conditional:
+  (* precedence here parses "if a then b + c" as "if a then (b + c)" *)
+  | IF; cond_expr = assignable_expr;
+    THEN; then_expr = assignable_expr;
+    ELSE; else_expr = assignable_expr
+    { If (with_pos $startpos $endpos, cond_expr, then_expr, Some else_expr) }
+    %prec ELSE
+  | IF; cond_expr = assignable_expr;
+    THEN; then_expr = assignable_expr
+    { If (with_pos $startpos $endpos, cond_expr, then_expr, None) }
+    %prec THEN
+  ;
Enter fullscreen mode Exit fullscreen mode

Branch discipline

diff --git a/lib/type.ml b/lib/type.ml
index c537187..88e5b3a 100644
--- a/lib/type.ml
+++ b/lib/type.ml
@@ -211,6 +211,8 @@ let rec translate venv expr =
   | FunctionDef (pos, def) -> translate_function_def venv (pos, def)
   | FunctionCall (pos, call) -> translate_function_call venv (pos, call)
   | Closure (pos, closure) -> translate_closure venv (pos, closure)
+  | If (pos, cond_expr, then_expr, else_expr_opt) ->
+      translate_conditional venv (pos, cond_expr, then_expr, else_expr_opt)
   | expr' ->
     error (Error.Msg.type_invalid_expr (string_of_type expr'))

@@ -704,6 +706,30 @@ and translate_closure_call venv (pos, def_params, body, call_args) =
     let* (_, body_type) = translate body_venv body in
     ok (venv, body_type)
Enter fullscreen mode Exit fullscreen mode

translate_conditional type checks the condition and both branches:

and translate_conditional venv (pos, cond_expr, then_expr, else_expr_opt) =
  let* (_, cond_ty) = translate venv cond_expr in
  match cond_ty with
  | Tbool ->
    let* (_, then_type) = translate venv then_expr in
    (match else_expr_opt with
    | Some else_expr ->
      let* (_, else_type) = translate venv else_expr in
      if then_type = else_type
      then ok (venv, then_type)
      else Error.error_at pos
        (Error.Msg.type_conditional_branches_mismatch
          ~then_type:(to_string then_type)
          ~else_type:(to_string else_type)
        )
    | None ->
      ok (venv, then_type)
    )
  | _ -> Error.error_at pos
    (Error.Msg.type_mismatch
      ~expected:(string_of_type (Bool (pos, true)))
      ~got:(string_of_type cond_expr)
    )
Enter fullscreen mode Exit fullscreen mode

When the else branch is absent, the then branch is the return type of the entire expression. When both are present, they must match — otherwise we raise a type mismatch error.

This is a deliberate design choice. TypeScript would return a union type like string | number, which is flexible and plays well with dynamic languages, but it adds complexity. Most statically typed languages require both branches to be the same type when returning an expression — setting aside languages that treat conditional branches as statements, those are a different story.

And the new error message:

diff --git a/lib/error.ml b/lib/error.ml
index cefc8d1..b1ae567 100644
--- a/lib/error.ml
+++ b/lib/error.ml
@@ -30,6 +30,10 @@ module Msg = struct
   let type_invalid_lookup_key expr = "Invalid object lookup key: " ^ expr
   let type_mismatch ~expected ~got =
     Printf.sprintf "Expected type %s, got %s" expected got
+  let type_conditional_branches_mismatch ~then_type ~else_type =
+    Printf.sprintf
+      "Conditional branches have different types: then branch returns %s, else branch returns %s"
+      then_type else_type

   (* Interpreter messages *)
   let interp_division_by_zero = "Division by zero"
diff --git a/lib/error.mli b/lib/error.mli
index b3e7da6..28cd496 100644
--- a/lib/error.mli
+++ b/lib/error.mli
@@ -26,6 +26,7 @@ module Msg : sig
   val type_non_indexable_field : string -> string
   val type_invalid_lookup_key : string -> string
   val type_mismatch : expected:string -> got:string -> string
+  val type_conditional_branches_mismatch : then_type:string -> else_type:string -> string

   (* Interpreter messages *)
   val interp_division_by_zero : string
Enter fullscreen mode Exit fullscreen mode

The boring part (by design)

The interpreter is the boring part. It just picks one branch or the other:

diff --git a/lib/interpreter.ml b/lib/interpreter.ml
index 37e367e..235b90e 100644
--- a/lib/interpreter.ml
+++ b/lib/interpreter.ml
@@ -29,6 +29,8 @@ let rec interpret env expr =
   | FunctionDef (pos, def) -> interpret_function_def env (pos, def)
   | FunctionCall (pos, call) -> interpret_function_call env (pos, call)
   | Closure _ -> ok (env, expr)
+  | If (pos, cond_expr, then_expr, else_expr_opt) ->
+      interpret_conditional env (pos, cond_expr, then_expr, else_expr_opt)

 and interpret_indexed_expr env (pos, varname, index_expr) =
   let* (env', index_expr') = interpret env index_expr in
Enter fullscreen mode Exit fullscreen mode
and interpret_conditional env (pos, cond_expr, then_expr, else_expr_opt) =
  let* (_, cond) = interpret env cond_expr in
  match cond with
  | Bool (_, true) ->
    interpret env then_expr
  | Bool (_, false) ->
    (match else_expr_opt with
    | Some else_expr -> interpret env else_expr
    | None -> ok (env, Null pos)
    )
  | _ ->
    (* Unreachable: type checker ensures cond_expr is Bool *)
    Error.error_at pos "interpret_conditional: non-boolean condition (type checker should prevent this)"
Enter fullscreen mode Exit fullscreen mode

The unreachable branch at the end is starting to bother me. The type checker already guarantees the condition is a Bool, so that match arm will never fire. I keep thinking about using phantom types to encode this statically and let the compiler enforce it, instead of leaving a comment that future me will pretend not to see. Something to revisit.

Tests

And it works:

$ dune exec -- tsonnet samples/tutorials/conditionals.jsonnet
{
  "Large Mojito": {
    "garnish": "Lime wedge",
    "ingredients": [
      { "action": "muddle", "kind": "Mint", "qty": 12, "unit": "leaves" },
      { "kind": "Banks", "qty": 3.0 },
      { "kind": "Lime", "qty": 1.0 },
      { "kind": "Simple Syrup", "qty": 1.0 },
      { "kind": "Soda", "qty": 6 }
    ],
    "served": "Over crushed ice"
  },
  "Mojito": {
    "garnish": null,
    "ingredients": [
      { "action": "muddle", "kind": "Mint", "qty": 6, "unit": "leaves" },
      { "kind": "Banks", "qty": 1.5 },
      { "kind": "Lime", "qty": 0.5 },
      { "kind": "Simple Syrup", "qty": 0.5 },
      { "kind": "Soda", "qty": 3 }
    ],
    "served": "Over crushed ice"
  },
  "Virgin Mojito": {
    "garnish": null,
    "ingredients": [
      { "action": "muddle", "kind": "Mint", "qty": 6, "unit": "leaves" },
      { "kind": "Lime", "qty": 0.5 },
      { "kind": "Simple Syrup", "qty": 0.5 },
      { "kind": "Soda", "qty": 3 }
    ],
    "served": "Over crushed ice"
  }
}
Enter fullscreen mode Exit fullscreen mode

The cram tests will keep it honest:

diff --git a/samples/conditionals/conditionals.jsonnet b/samples/conditionals/conditionals.jsonnet
new file mode 100644
index 0000000..6b39918
--- /dev/null
+++ b/samples/conditionals/conditionals.jsonnet
@@ -0,0 +1,17 @@
+{
+    'cond_true': if true then 'if true works!',
+    'cond_null': if false then 'unreachable!',
+    'cond_else_true': if true then 'then branch' else 'else branch',
+    'cond_else_false': if false then 'then branch' else 'else branch',
+    'cond_else_expr': if true then 10 + 5 else 20 + 5,
+    'cond_nested_object': {
+        result: if false then 'not this' else 'this one'
+    },
+    'cond_nested_chain':
+        if true then
+            if false then 0
+            else
+                if true then 42
+                else 1
+        else 2
+}
diff --git a/samples/semantics/invalid_conditional_branches_type.jsonnet b/samples/semantics/invalid_conditional_branches_type.jsonnet
new file mode 100644
index 0000000..1ea1aef
--- /dev/null
+++ b/samples/semantics/invalid_conditional_branches_type.jsonnet
@@ -0,0 +1 @@
+if true then 1 else "oops"

diff --git a/test/cram/semantics.t b/test/cram/semantics.t
index 3f26924..14f5911 100644
--- a/test/cram/semantics.t
+++ b/test/cram/semantics.t
@@ -324,3 +324,11 @@
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   [1]

+
+  $ tsonnet ../../samples/semantics/invalid_conditional_branches_type.jsonnet
+  ERROR: ../../samples/semantics/invalid_conditional_branches_type.jsonnet:1:0 Conditional branches have different types: then branch returns Number, else branch returns String
+  
+  1: if true then 1 else "oops"
+     ^^^^^^^^^^^^^^^^^^^^^^^^^^
+  [1]
+
diff --git a/test/cram/tutorials.t b/test/cram/tutorials.t
index 9990a7e..b3c1bc9 100644
--- a/test/cram/tutorials.t
+++ b/test/cram/tutorials.t
@@ -92,6 +92,42 @@
     }
   }
+  $ tsonnet ../../samples/tutorials/conditionals.jsonnet
+  {
+    "Large Mojito": {
+      "garnish": "Lime wedge",
+      "ingredients": [
+        { "action": "muddle", "kind": "Mint", "qty": 12, "unit": "leaves" },
+        { "kind": "Banks", "qty": 3.0 },
+        { "kind": "Lime", "qty": 1.0 },
+        { "kind": "Simple Syrup", "qty": 1.0 },
+        { "kind": "Soda", "qty": 6 }
+      ],
+      "served": "Over crushed ice"
+    },
+    "Mojito": {
+      "garnish": null,
+      "ingredients": [
+        { "action": "muddle", "kind": "Mint", "qty": 6, "unit": "leaves" },
+        { "kind": "Banks", "qty": 1.5 },
+        { "kind": "Lime", "qty": 0.5 },
+        { "kind": "Simple Syrup", "qty": 0.5 },
+        { "kind": "Soda", "qty": 3 }
+      ],
+      "served": "Over crushed ice"
+    },
+    "Virgin Mojito": {
+      "garnish": null,
+      "ingredients": [
+        { "action": "muddle", "kind": "Mint", "qty": 6, "unit": "leaves" },
+        { "kind": "Lime", "qty": 0.5 },
+        { "kind": "Simple Syrup", "qty": 0.5 },
+        { "kind": "Soda", "qty": 3 }
+      ],
+      "served": "Over crushed ice"
+    }
+  }
+
   $ tsonnet ../../samples/tutorials/arith.jsonnet
   {
     "concat_array": [ 1, 2, 3, 4 ],
Enter fullscreen mode Exit fullscreen mode

Conclusion

Conditionals are in. Not a lot of ceremony for something this fundamental — which is usually the sign of a clean design paying off.

Next up: probably, for loops and comprehensions.

The entire diff is available here.


Thanks for reading Bit Maybe Wise! If conditionals finally work in Tsonnet, maybe you should conditionally subscribe — but make the condition true.

Photo by Marco Kaufmann on Unsplash

Top comments (0)