okay-effects

Start with okay-simple.

Algebraic effects in Scala, in six short steps. Each block on this page was compiled and run against the released 0.1.0 from its Maven repository, on Scala 3.3 LTS and on Scala 3.9. The values in the comments are what it printed.

1. Add it to the build

The releases are a Maven repository on GitHub Pages. You need one resolver and the modules you use. Use %%% for Scala.js and Scala Native. The libraries are built on Scala 3.3 LTS and on Scala 2.13, so any Scala 3 from 3.3 on can use them.

resolvers += "okay-effects" at "https://okay-effects.github.io/okay-simple/maven"

libraryDependencies ++= Seq(
  "io.github.okay-effects" %% "okay-effects"         % "0.1.0",  // the interface and the standard effects
  "io.github.okay-effects" %% "okay-effects-machine" % "0.1.0",  // an implementation to run on
  "io.github.okay-effects" %% "okay-effects-stream"  % "0.1.0"   // streams, used in step 6
)

The full list of modules is in the repository index: implementations (-free, -tagless), effects (-async, -concurrent, -resource, -io, -probability, -parsing, -logic, -transaction, -resilience) and interop (-zio, -cats, -fs2, -kyo, -monix, -java-stream, -tofu).

2. A first program: a small service

A program A ! R gives an A, and on its way it performs operations of the effects in the row R. The service reads its configuration (Reader), writes a log (Writer) and counts the requests it served (State). Its type, String ! Service, says exactly that. The row is named once, in std.On[Service], and inside it the words take no types: ask[Config], get, put, tell. The handlers give the meaning: here a config value, a log collected into a list and a counter starting at 0. In production they would read a file, write to a log system and keep the counter in a store. The program would not change, and the second run, with a different config, shows how easily its behaviour is tested. Nothing runs until run.

import okay.effects.*
import okay.effects.std.*

case class Config(greeting: String, limit: Int)

type Service[X] = (Reader % Config + Writer % String + State % Int)[X]

// the row named once; inside, the words need no types
object service extends std.On[Service]:
  def serve(user: String): String ! Service =
    for
      c <- ask[Config]
      n <- get
      reply <- if n >= c.limit then tell(s"refused $user: busy").as("try later")
               else put(n + 1).andThen(tell(s"served $user")).as(s"${c.greeting} $user")
    yield reply

val day = for a <- service.serve("ann"); b <- service.serve("bob"); c <- service.serve("cid") yield List(a, b, c)

// the handlers: a config, a log collected, a counter from 0
run(state(0)(writer[String](reader(Config("hello", 2))(day))))
// (2,(List(served ann, served bob, refused cid: busy),List(hello ann, hello bob, try later)))

run(state(0)(writer[String](reader(Config("hi", 5))(day))))
// (3,(List(served ann, served bob, served cid),List(hi ann, hi bob, hi cid)))

On Scala 3.3 a row alias takes its parameter, type Service[X] = (…)[X]. From Scala 3.5 on, type Service = … works too. Each handler takes its effect from wherever it is in the row, so they can be applied in any order.

3. Your own effect, and its handler

An effect is an interface: an enum of operations, each typed by its answer. Has[Counter] says that a program needs it, in any row.

enum Counter[X]:
  case Tick extends Counter[Unit]
  case Peek extends Counter[Int]

def tick[R[_]: Has[Counter]]: Unit ! R = perform[Counter](Counter.Tick)
def peek[R[_]: Has[Counter]]: Int ! R = perform[Counter](Counter.Peek)

Its handler gives the operations a meaning. This one answers in place and keeps a count in a cell. Split is the evidence that Counter is somewhere in R, with Rest the row without it, so the handler works in any row.

def counting[R[_], Rest[_], A](p: A ! R)(using Split[Counter, R, Rest]): (Int, A) ! Rest =
  handle(p, 0, new StatefulTailHandler[Counter, Int, Rest, A, (Int, A)]:
    def ret(n: Int, a: A) = pure((n, a))
    def answer[X](cell: Cell[Int], op: Counter[X]): X = op match
      case Counter.Tick => cell.set(cell.get + 1)
      case Counter.Peek => cell.get)

run(counting(tick[Counter].andThen(tick[Counter]).andThen(peek[Counter])))   // (2,2)

4. A handler that resumes twice

A general handler gets the rest of the program as k, a function, and it may call k any number of times. Here it is called with true and then with false, so two flips give all four outcomes. The same mechanism gives search, backtracking and probability distributions.

enum Coin[X]:
  case Flip extends Coin[Boolean]

def flip[R[_]: Has[Coin]]: Boolean ! R = perform[Coin](Coin.Flip)

def outcomes[R[_], Rest[_], A](p: A ! R)(using Split[Coin, R, Rest]): List[A] ! Rest =
  handle(p, new Handler[Coin, Rest, A, List[A]]:
    def ret(a: A) = pure(List(a))
    def op[X](op: Coin[X], k: X => List[A] ! Rest) = op match
      case Coin.Flip => k(true).flatMap(t => k(false).map(t ++ _)))

run(outcomes(for a <- flip[Coin]; b <- flip[Coin] yield (a, b)))
// List((true,true), (true,false), (false,true), (false,false))

5. Errors: raised, recovered

Error is a standard effect. attempt turns it into an Either, and recover replaces the rest of the program with a recovery. It is the exception that a handler need not resume, next to the ones it does resume.

type Failing[X] = (Error % String)[X]

object failing extends std.On[Failing]:
  def half(n: Int): Int ! Failing =
    if n % 2 == 0 then pure(n / 2) else raise(s"$n is odd")

import failing.half

run(attempt[String](half(10)))               // Right(5)
run(attempt[String](half(7)))                // Left(7 is odd)
run(recover[String](half(7))(_ => pure(-1)))  // -1

6. Streams pulled by effects

A Stream[O, R] gives elements one at a time, each pull a program of R. It is lazy, so it can be endless. Its elements can use any effect, such as a state here.

import okay.effects.stream.*

type St[X] = (State % Int)[X]

run(range[Pure](1, 6).filter(_ % 2 == 1).map(_ * 10).toList)   // List(10, 30, 50)

// each element pulled by an effect: a running total in a state
run(state(0)(range[St](1, 4).evalMap(n => modify[Int, St](_ + n).andThen(get[Int, St])).toList))
// (6,List(1, 3, 6))

Next

The tour

Code that is generic in its row, choice, generators, interception and delimited control. Then tagless: one program on any implementation.

The guide and the modules

Handlers in five shapes, the standard effects, async with cancellation, resources, transactions, logic and parsing.

With what you have

ZIO, with its environment, errors and layers as effects, plus cats-effect, fs2, kyo and monix. Bring effects into a codebase one part at a time.

Scala 2.13: the same library, with rows written by names, (State % Int)#L. See getting started. Questions and help: the author.