1 people like it.
Like the snippet!
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