DEV Community

Hercules Lemke Merscher
Hercules Lemke Merscher

Posted on • Originally published at open.substack.com

Tsonnet #7 - No more YOLO exceptions

Welcome to the Tsonnet series!

If you're just joining us, you can check out how it all started here.

In the previous post, we covered simple string concatenation:

Today, we'll improve our codebase by replacing ad-hoc exceptions with monadic error handling. If you're not familiar with this concept, I recommend reading my introduction to monadic error handling before continuing:

Adding monadic error handling to Tsonnet

Let's start from the bottom up by replacing exceptions in the Json module with Result values. The Result type in OCaml provides a way to handle errors explicitly in our type system, making error cases visible and impossible to ignore:

diff --git a/lib/json.ml b/lib/json.ml
index 886d2bd..9b3596f 100644
--- a/lib/json.ml
+++ b/lib/json.ml
@@ -1,17 +1,27 @@
 open Ast
+open Result

-let rec expr_to_yojson : (expr -> Yojson.t) = function
+let rec expr_to_yojson : expr -> (Yojson.t, string) result = function
   | Number n ->
-    (match n with
+    ok (match n with
     | Int i -> `Int i
     | Float f -> `Float f)
-  | Null -> `Null
-  | Bool b -> `Bool b
-  | String s -> `String s
-  | Array values -> `List (List.map expr_to_yojson values)
+  | Null -> ok `Null
+  | Bool b -> ok (`Bool b)
+  | String s -> ok (`String s)
+  | Array values ->
+    let expr_to_list expr' = to_list (expr_to_yojson expr') in
+    let results = values |> List.map expr_to_list |> List.concat in
+    ok (`List results)
   | Object attrs ->
-    let eval' = fun (k, v) -> (k, expr_to_yojson v)
-    in `Assoc (List.map eval' attrs)
-  | _ -> failwith "value type not representable as JSON"
+    let eval' = fun (k, v) ->
+      let result = expr_to_yojson v
+      in Result.map (fun val' -> (k, val')) result
+    in
+    let results = attrs |> List.map eval' |> List.map to_list |> List.concat
+    in ok (`Assoc results)
+  | _ -> error "value type not representable as JSON"

-let expr_to_string expr = Yojson.pretty_to_string (expr_to_yojson expr)
+let expr_to_string expr =
+  let yojson = expr_to_yojson expr
+  in Result.map Yojson.pretty_to_string yojson
Enter fullscreen mode Exit fullscreen mode

The key changes here involve:

  • Changing the return type to (Yojson.t, string) result, where the first type parameter represents success values and the second represents error messages
  • Using ok and error constructors to wrap our return values
  • Carefully handling recursive cases in Array and Object types, where we need to process lists of results

Following this change, the interpreter needs to be updated. Here we mostly use the ok function to wrap the values, but we can also get rid of one failwith raising an exception for the invalid binary operation case:

diff --git a/lib/tsonnet.ml b/lib/tsonnet.ml
index 7ed044a..f443742 100644
--- a/lib/tsonnet.ml
+++ b/lib/tsonnet.ml
@@ -1,4 +1,7 @@
 open Ast
+open Result
+
+let (let*) = Result.bind

 (** [parse s] parses [s] into an AST. *)
 let parse (s: string) : expr =
@@ -26,16 +29,19 @@ let interpret_bin_op (op: bin_op) (n1: number) (n2: number) : expr =
   | Divide, (Float a), (Float b) -> Number (Float (a /. b))

 (** [interpret expr] interprets and reduce the intermediate AST [expr] into a result AST. *)
-let rec interpret (e: expr) : expr =
+let rec interpret (e: expr) : (expr, string) result =
   match e with
-  | Null | Bool _ | String _ | Number _ | Array _ | Object _ -> e
+  | Null | Bool _ | String _ | Number _ | Array _ | Object _ -> ok e
+  | BinOp (Add, String a, String b) -> ok (String (a^b))
   | BinOp (op, e1, e2) ->
-    match (interpret e1, interpret e2) with
-    | String a, String b -> String (a^b)
-    | Number v1, Number v2 -> interpret_bin_op op v1 v2
-    | _ -> failwith "invalid binary operation"
+    let* e1' = interpret e1 in
+    let* e2' = interpret e2 in
+    match e1', e2' with
+    | String a, String b -> ok (String (a^b))
+    | Number v1, Number v2 -> ok (interpret_bin_op op v1 v2)
+    | _ -> error "invalid binary operation"

-let run (s: string) =
-  let ast = parse s in
-  let evaluated_ast = interpret ast in
-  Json.expr_to_string evaluated_ast
+let run (s: string) : (string, string) result =
+  let expr = parse s in
+  let* expr = interpret expr in
+  Json.expr_to_string expr
Enter fullscreen mode Exit fullscreen mode

Note the let* definition as an alias for Result.bind. This syntactic sugar makes our code much cleaner when working with Result values. Instead of deeply nested match expressions, we can write sequential-looking code that maintains proper error handling -- if any step in the chain produces an error, execution stops and returns that error.

Lastly, main.ml is required to match the result type accordingly:

diff --git a/bin/main.ml b/bin/main.ml
index 723b106..5747a00 100644
--- a/bin/main.ml
+++ b/bin/main.ml
@@ -7,8 +7,9 @@ let run_parser filename =
   let input_channel = open_in filename in
   let content = really_input_string input_channel (in_channel_length input_channel) in
   close_in input_channel;
-  let result = Tsonnet.run content in
-  print_endline result
+  match Tsonnet.run content with
+  | Ok stringified_json -> print_endline stringified_json
+  | Error err -> failwith err

 let () =
   Arg.parse spec_list anonymous_fun usage_msg;
Enter fullscreen mode Exit fullscreen mode

Conclusion

With these changes, our error handling is now:

  • Type-safe: The compiler ensures we handle all error cases
  • Explicit: Error paths are visible in our function signatures
  • Composable: We can easily chain operations while maintaining proper error handling

In the next posts, we'll build upon this foundation to add more complex language features while maintaining our robust error-handling approach.


Thanks for reading Bit Maybe Wise! Subscribe and I'll Result.bind you to more Tsonnet posts!

Top comments (0)