1 people like it.

Zio like monad with extensible error handling

Sketch of a Zio like monad in F# for computations with extensible environment, extensible error handling, and asynchronicity.

  1: 
  2: 
  3: 
  4: 
  5: 
  6: 
  7: 
  8: 
  9: 
 10: 
 11: 
 12: 
 13: 
 14: 
 15: 
 16: 
 17: 
 18: 
 19: 
 20: 
 21: 
 22: 
 23: 
 24: 
 25: 
 26: 
 27: 
 28: 
 29: 
 30: 
 31: 
 32: 
 33: 
 34: 
 35: 
 36: 
 37: 
 38: 
 39: 
 40: 
 41: 
 42: 
 43: 
 44: 
 45: 
 46: 
 47: 
 48: 
 49: 
 50: 
 51: 
 52: 
 53: 
 54: 
 55: 
 56: 
 57: 
 58: 
 59: 
 60: 
 61: 
 62: 
 63: 
 64: 
 65: 
 66: 
 67: 
 68: 
 69: 
 70: 
 71: 
 72: 
 73: 
 74: 
 75: 
 76: 
 77: 
 78: 
 79: 
 80: 
 81: 
 82: 
 83: 
 84: 
 85: 
 86: 
 87: 
 88: 
 89: 
 90: 
 91: 
 92: 
 93: 
 94: 
 95: 
 96: 
 97: 
 98: 
 99: 
100: 
101: 
102: 
103: 
104: 
105: 
106: 
107: 
108: 
109: 
110: 
111: 
112: 
113: 
114: 
115: 
116: 
117: 
118: 
119: 
120: 
121: 
122: 
123: 
124: 
125: 
126: 
127: 
128: 
129: 
130: 
131: 
132: 
133: 
134: 
135: 
136: 
137: 
138: 
139: 
140: 
141: 
142: 
143: 
144: 
145: 
146: 
147: 
148: 
149: 
150: 
151: 
152: 
153: 
154: 
155: 
156: 
157: 
158: 
159: 
160: 
161: 
162: 
163: 
164: 
165: 
166: 
167: 
168: 
169: 
170: 
171: 
172: 
173: 
174: 
175: 
176: 
177: 
178: 
179: 
180: 
181: 
182: 
183: 
184: 
185: 
186: 
187: 
188: 
189: 
190: 
191: 
192: 
193: 
194: 
195: 
196: 
197: 
198: 
199: 
200: 
201: 
202: 
203: 
204: 
205: 
206: 
207: 
208: 
209: 
210: 
211: 
212: 
213: 
214: 
215: 
216: 
217: 
218: 
219: 
220: 
221: 
222: 
223: 
224: 
225: 
226: 
227: 
228: 
229: 
230: 
231: 
232: 
233: 
234: 
235: 
236: 
237: 
238: 
239: 
240: 
241: 
242: 
243: 
244: 
245: 
246: 
247: 
248: 
249: 
250: 
251: 
252: 
253: 
254: 
255: 
256: 
257: 
258: 
259: 
260: 
261: 
262: 
263: 
264: 
265: 
266: 
267: 
268: 
269: 
270: 
271: 
272: 
273: 
274: 
275: 
276: 
277: 
278: 
279: 
280: 
281: 
282: 
283: 
284: 
285: 
286: 
287: 
288: 
289: 
290: 
291: 
292: 
293: 
294: 
295: 
296: 
297: 
298: 
299: 
300: 
301: 
302: 
303: 
304: 
305: 
306: 
307: 
308: 
309: 
310: 
311: 
312: 
313: 
314: 
315: 
316: 
317: 
318: 
319: 
320: 
321: 
322: 
323: 
324: 
325: 
326: 
327: 
328: 
329: 
330: 
331: 
332: 
333: 
334: 
335: 
336: 
337: 
338: 
339: 
// Computations with extensible environment, error handling, and asynchronicity

// I recently reviewed some F# code that turned out to be using
//
//   Dependency Interpretation
//   https://fsharpforfunandprofit.com/posts/dependencies-4/
//
// and got thinking about whether one could construct a usable Zio like monad
//
//   https://zio.dev/
//
// in F# with an extensible environment, error handling, and asynchronicity.

// The way I might put it, a primary motivation for using such a thing is that
// it allows parts of application code to be parameterized with respect to
// contextual dependencies, such as database connections or logging facilities,
// in a relatively convenient manner.  This parameterization then makes it easy
// to run parts of the application code in various contexts such as actual
// production and under e.g. a unit testing environment.

// Of course, it has already been known for a long time that we can achieve this
// kind of extensibility in F# by using type constraints on type variables.
// Scott Wlaschin explains the technique in
//
//   Dependency injection using the Reader monad
//   https://fsharpforfunandprofit.com/posts/dependencies-3/
//
// and there are advanced libraries for F# using (in part) similar techniques
// such as
//
//   Eff
//   https://github.com/palladin/Eff
//
// by Nick Palladinos.

// So, the technicality I'm particular interested in is in how one might make
// the error handling mechanism extensible.  More specifically, it should be
// easy to introduce new error types, raise errors of such types, and handle
// errors.  Furthermore, it would be nice to have the combination of errors
// potentially raised be inferred by the compiler and it would be nice that
// errors could be handled and removed from the combination.  Essentially a kind
// of checked exceptions.  Of course, as argued by Eirik Tsarpalis,
//
//   You’re better off using Exceptions
//   https://eiriktsarpalis.wordpress.com/2017/02/19/youre-better-off-using-exceptions/
//
// in most cases, but curiosity got the better of me.

// Without further ado, let's sketch such a Zio style monad!

// First let's the define the `Zio<'r, 'h, 'a>` type:

type Zio<'r, 'h, 'a> =
  { go: 'r -> 'h -> ('a -> unit) -> unit }

// If you are familiar with Zio, then you might have noticed that I named the
// second type parameter `'h`, for "handler", rather than `'e`, for "error".
// That choice of word is the key to the extensible error mechanism.

// So, basically, a value of the `Zio<'r, 'h, 'a>` type is a computation that
// requires an environment of type `'r` and may either produce a value, or
// answer, of type `'a` or raise an error that needs a handler of type `'h`.

// The concrete implementation is essentially a function that takes the
// environment, handler, and a continuation as parameters.  The record wrapper,
// `{ go: ... }`, is there just to make the inferred types more readable.

// Below is the straightforward computation expression builder definition `zio`:

type ZioBuilder() =
  member _.Delay(f) = { go = fun r h k -> f().go r h k }
  member _.ReturnFrom xZ = xZ
  member _.Return x = { go = fun _ _ k -> k x }
  member this.Zero() = this.Return()
  member this.Combine(lZ, rZ) = this.Bind(lZ, (fun _ -> rZ))

  member _.Bind(xZ, xyZ) =
    { go = fun r h k -> xZ.go r h (fun x -> xyZ(x).go r h k) }

let zio = ZioBuilder()

// We also need a primitive operation for accessing the environment `ask`:

let ask = { go = fun r _ k -> k r }

// And, for convenience, let's define a helper for `call`ing methods of services
// passed through the environment:

let call fn = zio.Bind(ask, fn)

// So, with the above we can already write application code that is
// parameterized with respect to their dependencies.

// For example, we could define a service for reading lines of input

type IReadLn =
  abstract ReadLn : unit -> Zio<'r, 'h, option<string>>

let readLn () = call (fun (s: #IReadLn) -> s.ReadLn())

// and a service for writing lines of output

type IWriteLn =
  abstract WriteLn : string -> Zio<'r, 'h, unit>

let writeLn t =
  call (fun (s: #IWriteLn) -> s.WriteLn t)

// An essential detail above is the use of flexibly typed parameters `s:
// #IReadLn` and `s: #IWriteLn`.  It is a key to make the type inference for the
// usages work out nicely.

// As an example, we could now write a computation that copies all lines from
// input to the output:

let rec copyAll () =
  zio {
    match! readLn () with
    | None -> return ()
    | Some line ->
      do! writeLn line
      return! copyAll ()
  }

// The signature conveniently inferred for the `copyAll` computation
//
//   val copyAll:
//     unit -> Zio<'r, 'h, unit> when 'r :> IWriteLn and 'r :> IReadLn
//
// shows that `copyAll` requires the environment `'r` to provide both the
// `IWriteLn` and `IReadLn` interfaces.
//
// Note that the handler type `'h` remains unconstrained.  This means that the
// `copyAll` computation does not raise errors.

// Of course, things are rarely this simple.  In the above we essentially
// assumed that neither the `ReadLn` nor the `WriteLn` computation can fail.
// That is rarely a valid assumption and there are situations where we'd like to
// write code that is guaranteed to handle some failure conditions at some
// point.

// Did you react to the wording "handle some failure conditions at some point"?
// Perhaps it sounds rather vague.  However, the wording is intentional.  Some
// errors in a program are such that you never want to handle them.  You just
// let the program crash with a stack trace.  Other "errors" are such that you
// don't only want to handle them, but they are best expressed as an ordinary
// case of the result of an operation.  And then there are errors that you'd
// rather not handle after every operation, but you still want your program to
// handle them at some point.  Say, when performing a sequence of operations,
// you just want to make sure that any error from any step of the sequence will
// stop the sequence and will be handled e.g. by giving a suitable message to
// the user.  It is the last of these three cases that is of interest here.

// First we introduce a primitive operation to `throw` errors:

let throw e = { go = fun _ h _ -> e h }

// This is the first point where we make use of the `h` or handler.  The handler
// `h` is passed to the error `e`.

// We also need an operation to `catch` errors:

type ZioRunner<'r, 'h, 'a>(r: 'r, h: 'h, k: 'a -> unit) =
  inherit ZioBuilder()
  member _.Run(xZ: Zio<'r, 'h, 'a>) = xZ.go r h k

let catch h' xZ =
  { go = fun r h k -> xZ.go r (h' (ZioRunner(r, h, k))) k }

// The implementation here is a bit more tricky.  We want to allow error
// handlers to also perform arbitrary computations in the same monad.  As our
// monad uses continuation passing we can keep the types simple by passing in a
// special computation builder when constructing handlers.

// So, how does one use this error mechanism then?

// Well, to define a new error, one defines an interface for the handler of such
// errors.  As an example, let's define an error for unexpected end of input:

type IUnexpectedEndOfInput =
  abstract UnexpectedEndOfInput : unit -> unit

let UnexpectedEndOfInput (h: #IUnexpectedEndOfInput) = h.UnexpectedEndOfInput()

// The function `UnexpectedEndOfInput` is helper we use with `throw`.  Its usage
// looks like an error constructor.  Note again the use of a flexible type for
// the handler parameter.

// As an example, we could now define a `copy1` operation that copies a line
// of input to output or throws the error:

let copy1 () =
  zio {
    match! readLn () with
    | None -> return! throw UnexpectedEndOfInput
    | Some line -> return! writeLn line
  }

// The signature
//
//   val copy1:
//     unit -> Zio<'r, #IUnexpectedEndOfInput, unit>
//       when 'r :> IWriteLn and 'r :> IReadLn
//
// reflects both the environment and error handling requirements of the
// operation.

// Let's then define another error for too long lines

type ILineTooLong =
  abstract LineTooLong : max: int * actual: int -> unit

let LineTooLong e (h: #ILineTooLong) = h.LineTooLong e

// and another operation for copying a line of given maximum length

let copy1Of max =
  zio {
    match! readLn () with
    | None -> return! throw UnexpectedEndOfInput
    | Some line ->
      if max < line.Length then
        return! throw (LineTooLong(max, line.Length))

      return! writeLn line
  }

// Again, the inferred signature
//
//   val copy1Of:
//     max: int -> Zio<'r, 'h, unit>
//       when 'r :> IWriteLn and 'r :> IReadLn
//        and 'h :> ILineTooLong and 'h :> IUnexpectedEndOfInput
//
// reflects both the environment and error handling requirements.

// Let's put together the happy path of a little interaction:

let interaction () =
  zio {
    do! writeLn "Type me max 10 characters:"
    do! copy1Of 10
    do! writeLn "Thank you for your co-operation!"
  }

// Can you guess the signature of `interaction`?

// Alright, let's then figure out how we can actually run these kinds of
// computations.  For that purpose let's first define a primitive `startIn`
// operation:

let startIn r (uZ: Zio<'r, unit, unit>) = uZ.go r () id

// Notice that while the environment is allowed to be of any type, the handler
// (and the answer type) are required to be of type `unit`.  The effect of that
// is to ensure that no errors can be left unhandled and no (interesting) result
// may be ignored implicitly.

// How do we handle the errors?  First we need to define an interface that
// inherits all the handlers that our program requires.

type IHandler =
  inherit IUnexpectedEndOfInput
  inherit ILineTooLong

// Now we can define a `program` that performs the `interaction` and also
// catches the errors:

let program () =
  interaction ()
  |> catch (fun zio ->
    { new IHandler with
        member _.UnexpectedEndOfInput() =
          zio { return! writeLn "You gave me nothing!" }

        member _.LineTooLong(max, actual) =
          zio {
            return!
              writeLn (
                sprintf
                  "You gave me %d characters more than I asked!"
                  (actual - max)
              )
          } })

// What is the signature of `program`?
//
// Using `catch` allows handler constraints to be changed.  The old constraints
// are dropped and the new constraints are based on the error handling
// requirements of the handlers and following computation.  In this case the
// following computation has no further error handling requirements and the
// signature of `program`
//
//   val program:
//     unit -> Zio<'r, 'h, unit> when 'r :> IWriteLn and 'r :> IReadLn
//
// shows that.

// The next thing we need is to implement the environment.  We similarly need to
// define the combined environment signature:

type IEnv =
  inherit IReadLn
  inherit IWriteLn

// And finally we can implement the environment

let env =
  { new IEnv with
      member _.ReadLn() =
        zio {
          match System.Console.ReadLine() with
          | null -> return None
          | line -> return Some(line)
        }

      member _.WriteLn line = zio { System.Console.WriteLine line } }

// and start our program

do program () |> startIn env

// So, what do you think?
//
// What I like about this is that it is all rather simple and straightforward.
// There is no need for any major workarounds for type system deficiencies.  The
// type constraints for the environment and handlers are nicely inferred and are
// arguably quite readable.
//
// This sketch doesn't include anything asynchronous, but due to the use of
// continuation passing style and an extensible environment, async computations
// are easily subsumed and interoperated with.
//
// This sketch also doesn't do anything with or about exceptions.  In a real
// library you should think about and provide appropriate support for exception
// handling.
//
// This is, of course, just a sketch and a toy program, but, who knows, maybe
// you found some inspiration from this.
Zio.go: 'r -> 'h -> ('a -> unit) -> unit
type unit = Unit
Multiple items
type ZioBuilder =
  new : unit -> ZioBuilder
  member Bind : xZ:Zio<'a,'b,'c> * xyZ:('c -> Zio<'a,'b,'d>) -> Zio<'a,'b,'d>
  member Combine : lZ:Zio<'a,'b,'c> * rZ:Zio<'a,'b,'d> -> Zio<'a,'b,'d>
  member Delay : f:(unit -> Zio<'k,'l,'m>) -> Zio<'k,'l,'m>
  member Return : x:'g -> Zio<'h,'i,'g>
  member ReturnFrom : xZ:'j -> 'j
  member Zero : unit -> Zio<'e,'f,unit>

--------------------
new : unit -> ZioBuilder
val f : (unit -> Zio<'k,'l,'m>)
val r : 'k
val h : 'l
val k : ('m -> unit)
val xZ : 'j
val x : 'g
val k : ('g -> unit)
val this : ZioBuilder
member ZioBuilder.Return : x:'g -> Zio<'h,'i,'g>
val lZ : Zio<'a,'b,'c>
val rZ : Zio<'a,'b,'d>
member ZioBuilder.Bind : xZ:Zio<'a,'b,'c> * xyZ:('c -> Zio<'a,'b,'d>) -> Zio<'a,'b,'d>
val xZ : Zio<'a,'b,'c>
val xyZ : ('c -> Zio<'a,'b,'d>)
val r : 'a
val h : 'b
val k : ('d -> unit)
Zio.go: 'a -> 'b -> ('c -> unit) -> unit
val x : 'c
val zio : ZioBuilder
val ask : Zio<'a,'b,'a>
val k : ('a -> unit)
val call : fn:('a -> Zio<'a,'b,'c>) -> Zio<'a,'b,'c>
val fn : ('a -> Zio<'a,'b,'c>)
type IReadLn =
  interface
    abstract member ReadLn : unit -> Zio<'r,'h,string option>
  end
type Zio<'r,'h,'a> =
  { go: 'r -> 'h -> ('a -> unit) -> unit }
type 'T option = Option<'T>
Multiple items
val string : value:'T -> string

--------------------
type string = System.String
val readLn : unit -> Zio<#IReadLn,'b,string option>
val s : #IReadLn
abstract member IReadLn.ReadLn : unit -> Zio<'r,'h,string option>
type IWriteLn =
  interface
    abstract member WriteLn : string -> Zio<'r,'h,unit>
  end
val writeLn : t:string -> Zio<#IWriteLn,'b,unit>
val t : string
val s : #IWriteLn
abstract member IWriteLn.WriteLn : string -> Zio<'r,'h,unit>
val copyAll : unit -> Zio<'a,'b,unit> (requires 'a :> IWriteLn and 'a :> IReadLn)
union case Option.None: Option<'T>
union case Option.Some: Value: 'T -> Option<'T>
val line : string
val throw : e:('a -> unit) -> Zio<'b,'a,'c>
val e : ('a -> unit)
val h : 'a
Multiple items
type ZioRunner<'r,'h,'a> =
  inherit ZioBuilder
  new : r:'r * h:'h * k:('a -> unit) -> ZioRunner<'r,'h,'a>
  member Run : xZ:Zio<'r,'h,'a> -> unit

--------------------
new : r:'r * h:'h * k:('a -> unit) -> ZioRunner<'r,'h,'a>
val r : 'r
val h : 'h
val xZ : Zio<'r,'h,'a>
val catch : h':(ZioRunner<'a,'b,'c> -> 'd) -> xZ:Zio<'a,'d,'c> -> Zio<'a,'b,'c>
val h' : (ZioRunner<'a,'b,'c> -> 'd)
val xZ : Zio<'a,'d,'c>
val k : ('c -> unit)
Zio.go: 'a -> 'd -> ('c -> unit) -> unit
type IUnexpectedEndOfInput =
  interface
    abstract member UnexpectedEndOfInput : unit -> unit
  end
val UnexpectedEndOfInput : h:#IUnexpectedEndOfInput -> unit
val h : #IUnexpectedEndOfInput
abstract member IUnexpectedEndOfInput.UnexpectedEndOfInput : unit -> unit
val copy1 : unit -> Zio<'a,#IUnexpectedEndOfInput,unit> (requires 'a :> IWriteLn and 'a :> IReadLn)
type ILineTooLong =
  interface
    abstract member LineTooLong : max:int * actual:int -> unit
  end
val max : e1:'T -> e2:'T -> 'T (requires comparison)
Multiple items
val int : value:'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
val LineTooLong : int * int -> h:#ILineTooLong -> unit
val e : int * int
val h : #ILineTooLong
abstract member ILineTooLong.LineTooLong : max:int * actual:int -> unit
val copy1Of : max:int -> Zio<'a,'b,unit> (requires 'a :> IWriteLn and 'a :> IReadLn and 'b :> ILineTooLong and 'b :> IUnexpectedEndOfInput)
val max : int
property System.String.Length: int with get
val interaction : unit -> Zio<'a,'b,unit> (requires 'a :> IWriteLn and 'a :> IReadLn and 'b :> ILineTooLong and 'b :> IUnexpectedEndOfInput)
val startIn : r:'r -> uZ:Zio<'r,unit,unit> -> unit
val uZ : Zio<'r,unit,unit>
Zio.go: 'r -> unit -> (unit -> unit) -> unit
val id : x:'T -> 'T
type IHandler =
  interface
    inherit ILineTooLong
    inherit IUnexpectedEndOfInput
  end
val program : unit -> Zio<'a,'b,unit> (requires 'a :> IWriteLn and 'a :> IReadLn)
val zio : ZioRunner<'a,'b,unit> (requires 'a :> IWriteLn and 'a :> IReadLn)
val actual : int
val sprintf : format:Printf.StringFormat<'T> -> 'T
type IEnv =
  interface
    inherit IWriteLn
    inherit IReadLn
  end
val env : IEnv
namespace System
type Console =
  static member BackgroundColor : ConsoleColor with get, set
  static member Beep : unit -> unit + 1 overload
  static member BufferHeight : int with get, set
  static member BufferWidth : int with get, set
  static member CapsLock : bool
  static member Clear : unit -> unit
  static member CursorLeft : int with get, set
  static member CursorSize : int with get, set
  static member CursorTop : int with get, set
  static member CursorVisible : bool with get, set
  ...
System.Console.ReadLine() : string
System.Console.WriteLine() : unit
   (+0 other overloads)
System.Console.WriteLine(value: string) : unit
   (+0 other overloads)
System.Console.WriteLine(value: obj) : unit
   (+0 other overloads)
System.Console.WriteLine(value: uint64) : unit
   (+0 other overloads)
System.Console.WriteLine(value: int64) : unit
   (+0 other overloads)
System.Console.WriteLine(value: uint32) : unit
   (+0 other overloads)
System.Console.WriteLine(value: int) : unit
   (+0 other overloads)
System.Console.WriteLine(value: float32) : unit
   (+0 other overloads)
System.Console.WriteLine(value: float) : unit
   (+0 other overloads)
System.Console.WriteLine(value: decimal) : unit
   (+0 other overloads)

More information

Link:http://fssnip.net/85t
Posted:2 years ago
Author:Vesa Karvonen
Tags: computation expressions , reader monad