top of page
Search

Writing Modular Applications Using The Kyo Library

Written by Jorge Vásquez


The Functional Programming paradigm helps developers to write applications that are more concise, predictable and easier to test; and it shines specially on concurrent scenarios. In the Scala world, functional effect libraries such as ZIO and its ecosystem make working with this paradigm a delight, offering a great developer experience and with a constant focus on providing production-ready tools.


Now, there is a new player in the game: Kyo, a library authored by Flavio Brasil which brings some great innovations to the table and has been recently released at the Functional Scala 2023 conference, taking inspiration from ZIO itself in various aspects, but adding more flexibility on how effects are handled and with superb performance.


To see this library in action and to get us familiarized with it, we will implement a Tic-Tac-Toe game based on a previous ebook I’ve written for the Scalac blog, where the implementation was ZIO-based.  


Design of the Tic-Tac-Toe game

Let’s take a look at the design considerations we should take into account for the Tic-Tac-Toe game we are going to implement:

  • It should be a command-line application, so the game should be rendered into the console and the user should interact via text commands.

  • The application should have three modes, where a mode is defined by its state and a list of commands available to the user. These modes should be:

  • Confirm Mode: Must await for user confirmation, in the form of yes/no commands.

  • Menu Mode: Must allow the user to start, resume or quit a game.

  • Game Mode: Must implement the Game Logic itself and allow the user to play against an Opponent AI.

  • Our program should read from the Terminal, modify the state accordingly and write to the Terminal in a loop.

We will create a separate service for each of these concerns. Each service will depend on other services as depicted in the image below:




What is the Kyo Library for?

According to the Kyo documentation page: “Kyo is a toolkit for Scala development, spanning from browser-based apps in ScalaJS to high-performance backends on the JVM. It introduces a novel approach based on algebraic effects to deliver straightforward APIs in the pure Functional Programming paradigm. Unlike similar solutions, Kyo achieves this without inundating developers with esoteric concepts from Category Theory or using cryptic symbolic operators, resulting in a development experience that is both intuitive and robust.”


Something important to highlight from the above statement is that Kyo is an Algebraic Effects library, and that’s what makes it different from other Functional Effect libraries such as ZIO or Cats Effect. The idea is very interesting and it’s inspired by ZIO's effect rotation, however Kyo takes a more generalized approach. ZIO restricts effects to two channels, the environment channel and the error channel, and they can be rotated according to our needs. For example, if you want a ZIO[R, E, A] that:


  • Does not require an environment and cannot fail, just set R=Any and E=Nothing

  • Does not require an environment and that can fail with a Throwable, just set R=Any and E=Throwable

  • Requires an environment and that can fail with a Throwable, just set R=SomeEnvironment and E=Throwable


And so on. Also, because ZIO effects by their very own nature are immutable values that contain descriptions of interactions with the outside world (database queries, calls to APIs, etc.);  they always include the IO effect, and there is no way to disable it by setting some channel to Nothing or Any. If you want a data type that does not include IO, you should use ZPure from zio-prelude, for instance.


So, instead of having these restrictions, Kyo allows an arbitrary number of effectful channels, which means more flexibility in effect management. 


The < type, aka Pending

 

In Kyo, computations are pure, immutable values expressed via the infix type <, read as Pending.  It has two type parameters:


<[+T, -S] 

Where:

  • T represents the type of value a computation can succeed with.

  • S represents pending effects that need to be handled (the fact that this parameter is contravariant is very important, and we will see why in a bit).

For instance, if you have a value whose type is:


A < IOs


It would represent a computation which:

  • Succeeds with an A

  • Has interactions with the outside world (the IOs effect), which are pending to be handled at some moment.

And you would read it as: A pending IOs.

Now, if you would like to have a similar computation but that can also fail with some value of type E and that requires some environment of type R, its type would be:


A < (IOs with Envs [R] with Aborts [E])    // Scala 2
A < IOs & Envs [R] & Aborts [E])          // Scala 3

And you would read it as: A pending IOs and Envs[R] and Aborts[E].

Notice that:

  • You can combine several effects that are pending to be handled by using intersection types. In the above example, we are combining:

  • The IOs effect (interaction with the outside world)

  • The Envs effect (requiring something from the environment)

  • The Aborts effect (failing with some value and aborting the computation)

  • The order you use to combine the effects does not matter! So, for instance, the following types would be equivalent:


A < (IOs & Envs [R] & Aborts [E])
A < IOs & Envs [R] & Aborts [E]& IOs)

In Kyo, all the types representing effects have a name in plural form

  • Kyo offers multiple kinds of effects you can incorporate into your application according to your needs; besides IOs, Envs and Aborts. We will see some of them in action in the tic-tac-toe application we will implement later, but you can see the whole list in the Kyo documentation page.

  • This is the equivalent of ZIO[R, E, A]

Finally, if you want to represent a computation which does not have any pending effects to be handled, its type would be:


A < Any

Now, if you are wondering how pending effects are handled, don’t worry because we will see how to do that when implementing our game.


The importance of contravariance and intersection types in the effects channel


We have seen the < type is defined as:


<[+T, -S]

So, the effects channel S is contravariant. And, we have just seen that, in order to combine several effects, we use intersection types. The implications deriving from this are very important, for instance, if we have:


val a: Int < Envs[Int] & IOs) = ???

And we try to do this:

val b: Int < (Envs[Int] & IOs & Aborts [String]) = a
val c: Int < Envs[Int] = a

The compiler will be OK with the first statement, but it will fail on the second one. Why?


The reason is related to how contravariance and intersection types work together. So, in this case:


val b: Int < (Envs[Int] & IOs & Aborts [String]) = a

The interpretation of the type annotation would be: b is an effect which succeeds with an Int and has at most these pending effects: Envs[Int] and IOs and Aborts[String]. The important words to remember here are at most. That’s why assigning a to b doesn’t fail, because a is an effect whose pending effects (Envs[Int] and IOs) are less than those expected by b.


Now, in the next case:


val c: Int < Envs[Int] = a

We get a compilation error because c is an effect which succeeds with an Int and has at most Envs[Int] as pending effect. However, a has one more pending effect (IOs), so the assignment is invalid.


The Layer type

By the time of writing this article, a new Layer type has been added to Kyo, inspired by ZIO’s ZLayer. So, as in ZIO, Layer allows us to introduce dependency injection in our applications (which in Kyo means handling pending Envs effects). But actually, Layer allows us to handle any pending effect, not just Envs, so it’s more general. The type looks like this:


Layers [Sin, Sout]

The corresponding mental model would be:


(A < Sin) => (A < Sout)

So, you can think of a Layer as function which:

  • Takes a Kyo value with pending effects Sin

  • Returns a new Kyo value with the same success value, but with new pending effects Sout

By the way, similar to ZLayer, Layers can be combined in two fundamental ways:


Horizontally: By using the Layer#andThen method, which works like this:

(layer1: Layer[Sin1, Sout1]) andThen (layer2: Layer[Sin2, Sout2]) =>
result: Layer[Sin1 & Sin2, Sout1 & Sout2]

Vertically: By using the Layer#chain method, which works like this:

(layer1: Layer[Sin1, Sout1]) chain (layer2: Layer[Sout1, Sout2]) =>
result: Layer[Sin1, Sout2]

It’s important to mention that currently Kyo does not have a mechanism to automatically wire Layers as in ZIO, so for now we have to do this manually.


Implementing the Tic-Tac-Toe game


It’s time to implement the Tic-Tac-Toe application using Kyo! In the present article we will go deep into the source code of the most representative services, and you can always see the complete source code in the jorge-vasquez-2301/kyo-tictactoe repository.

Here is our build.sbt file (zio-parser will be used for parsing commands):


ThisBuild / version := "0.1.0-SNAPSHOT"

ThisBuild / scalaVersion := "3.3.1"

val kyoVersion = "0.8.0"

lazy val root = (project in file("."))
.settings(
  name := "kyo-tictactoe",
  libraryDependencies ++= Seq(
    "io.getkyo" %% "kyo-core"   % kyoVersion,
    "dev.zio"   %% "zio-parser" % "0.1.9"
  ),
  scalacOptions ++= Seq(
    "-Wunused:all",
    "-Werror"
  )
)

Notice we will be using Scala 3, but Kyo also supports Scala 2.


Writing the GameCommandParser Service


Here we have the interface of the GameCommandParser service, in the parser/game/GameCommandParser.scala file: 


package com.example.tictactoe.parsner.game
import com.example.tictactoe.domain.*
import kyo.*
import kyo.aborts.*
traits GameCommandParser:
   def parse(input: string): GameCommand < Aborts [AppError]

You can see GameCommandParser just exposes a single method: parse, which returns a Kyo value implying the following:

  • The method can succeed with a GameCommand

  • Or, it can fail with an AppError. This is reflected by the presence of the Aborts[AppError] effect.


Notice we are importing kyo.* so we have Kyo’s core stuff in scope such as the < type, and also kyo.aborts.* so we can use the Aborts effect.

 

Now, let’s see the implementation, located in the parser/game/GameCommandParserLive.scala file:


package com.example.tictactoe.parsner.game
import com.example.tictactoe.domain.*
import kyo.*
import kyo.aborts.*
import kyo.envs.*
import kyo.layers.*
import zio.parser.*

final class GameCommandParserlive() extends GameCommandParser:
   def parse (input: String): GameCommand < Aborts[AppError] =
     command.parseString(input) match
        case right (command) => command
        case left (_)        => Aborts [AppError].fail(AppError. ParseError)

private lazy val command: Parser [String, char, GameCommand] =
menu <> put

private lazy val menu: Parser [String, Char, GameCommand] =...
private lazy val put: Parser [String, Char, GameCommand] =...

You can see we have a command value of type Parser, this type comes from the zio-parser  library and it will basically allow users to introduce a menu command or digits to choose a board position (field) while playing the game. I won’t go into detail about how zio-parser works, but you can check its documentation if you want to know more.


The command parser is used by the parse method, let’s analyze how it works: command.parseString is called with the given input, this returns an Either[String, GameCommand]

  • If the parsing result is a failure, parse should fail with an AppError, so we need to call the Aborts[AppError].fail method (notice the AppError type parameter is required)

  • Otherwise, parse should succeed with the corresponding command. 


What’s interesting is that in the success case we didn’t need to wrap the resulting command at all! For instance, in ZIO we would have to do ZIO.succeed(command), but here that’s not necessary because Kyo is able to automatically unify the types from both the error and success branches like this:

  • The result type in the error branch would be: Nothing < Aborts[AppError], so it represents a Kyo value that cannot succeed and always fails with an AppError (it has a pending Aborts[AppError] effect)

  • The result type in the success branch is simply GameCommand, which is automatically lifted to GameCommand < Any, so it represents a Kyo value that always succeeds with a GameCommand and has no pending effect (The Kyo documentation explains this clearly)

  • So, after type unification we would have GameCommand < (Aborts[AppError] & Any), which is equivalent to GameCommand < Aborts[AppError], meaning the result is a Kyo value that can succeed with a GameCommand, or fail with an AppError.


Finally, let’s see the GameCommandParserLive companion object:


object GameCommandParserLive:
  val layer: Layer[Envs[GameCommandParser], Any] = 
    Envs [GameCommandParser].layer(GameCommandParserLive())

You can see we are creating a Layer that we will use later for dependency injection (so, it handles Envs effects), by calling Envs[GameCommandParser].layer, which expects a Kyo value that describes how to construct a GameCommandParser. In this case, you can see we are just passing a new GameCommandParserLive instance, whose type is automatically lifted to GameCommandParser < Any. And, because no dependencies are required for constructing GameCommandParserLive, the resulting Layer will have a type Layer[Envs[GameCommandParser], Any].


(Just a side note here: If you want to construct a Layer that handles other kinds of effects, such as Aborts[E], you would need to call Aborts[E].layer).


Writing the GameLogic Service


The interface of the GameLogic service is defined in the gameLogic/GameLogic.scala file:


package com.example.tictactoe.gameLogic
import com.example.tictactoe.domain.*
import kyo.*
import kyo.aborts.*
import kyo.ios.*
trait GameLogic:
def putPiece(board: Board, field: Field, piece: Piece): Board < Aborts[AppError]
def gameResult(board: Board): GameResult < IOs
def nextTurn(currentTurn: Piece): Piece

Here we see some interesting things:


  • The putPiece method’s return type indicates it can fail with an AppError, since there is an Aborts[AppError] pending effect

  • The gameResult method’s return type indicates it cannot fail (at least in a recoverable way, we will see why I’m saying this in a bit) because we are not including Aborts as a pending effect; however, it will execute some IO, and we know this because we are including IOs in the type signature.

  • nextTurn does not perform any effects


Let’s see the implementation now (gameLogic/GameLogicLive.scala file). Looking at the putPiece method specifically:


import kyo.*
import kyo.aborts.*
import kyo.envs.*
import kyo.layers.*
import kyo.ios.*

def putPiece(board: Board, field: Field, piece: Piece) Board < Aborts[AppError] =
if board.fieldIsFree(field) then board.updated (field, piece)
else Aborts [AppError].fail(AppError.FieldAlreadyOccupiedError)

We can see Kyo’s power in action once again:


  • For the success case (the Field where the user wants to put a Piece is free), we just return a Board value

  • For the failure case, we just return an Aborts[AppError]

  • Kyo takes care of unifying the return types of both branches for us


Now, let’s analyze the gameResult method:


def gameResult(board: Board): GameResult < IOs = 
 val crossWin  = board.isPieceWinner(Piece.X)
  val noughtWin = board.isPieceWinner(Piece.O) 

 if crossWin && noughtWin then  
  IOs.fail {    
  IllegalStateException(
        "It should not be possible for both players to meet winning conditions."   
   )  
  } 
else if crossWin then GameResult.Win(Piece.Xelse if noughtWin then GameResult.Win(Piece.Oelse if board.isFull then GameResult.Draw
else GameResult.Ongoing

The implementation is pretty straightforward, but there’s something interesting to notice: on the first branch of the if expression, we are checking the case where both cross and nought win the game, which should not be possible at all! If this case actually happened, that would mean there’s a defect in our application, and we would like it to terminate immediately with an exception, and that’s why we are failing with IOs.fail instead of Aborts[AppError].fail. So, the important lesson here is:


  • If a method fails with a recoverable error, it should fail by means of an Aborts effect.

  • If a method fails with an unrecoverable error (a defect) it should fail by means of IOs.fail. (A side note for those who know ZIO: this would be the equivalent of calling ZIO.die).


Finally, we have the corresponding Layer in the companion object:


object GameLogicLive:
 val layer: Layer[Envs[GameLogic], Any] = Envs[GameLogic].layer(GameLogicLive())

Writing the OpponentAi Service


This service is very simple, here is its interface (opponentAi/OpponentAi.scala file):



package com.example.tictactoe.opponentAi
import com.example.tictactoe.domain.{Board, Field}
import kyo.*
import kyo.ios.*
trait OpponentAidef randomMove(board: Board): Field < IOs

And the implementation (opponentAi/OpponentAiLive.scala file):


package com.example.tictactoe.opponentAi

import com.example.tictactoe.domain.{ Board, Field }
import kyo.*
import kyo.ios.*
import kyo.randoms.*
import kyo.envs.*
import kyo.layers.*

final class OpponentAiLive() extends OpponentAi:
  override def randomMove(board: Board): Field < IOs = 
   val unoccupied = board.unoccupiedFields    board.unoccupiedFields.size match    
  case 0 => IOs.fail(IllegalStateException("Board is full"))      case n => Randoms.nextInt(n).map(unoccupied(_))

There’s some new things here:


  • The randomMove method needs to generate a random integer. For that, we are calling nextInt from the standard Randoms service from Kyo, whose return type is Int < IOs

  • We want to transform the output of Randoms.nextInt, and for that we can use the map method, available to any Kyo value. As you would expect if you are experienced with other effect libraries like ZIO, map allows to transform the output of an effect from a type A to a type B.


And, as in previous services, we have a Layer in the companion object:


object OpponentAiLiveval layer: Layer[Envs[OpponentAi], Any] = Envs[OpponentAi].layer(OpponentAiLive())

Writing the Terminal Service

This is another simple service, but we will learn some new things. The interface is defined in the terminal/Terminal.scala file:


package com.example.tictactoe.terminal

import kyo.*
import kyo.ios.*

trait Terminal:
  def getUserInput: String < IOs
  def display(frame: String): Unit < IOs

Here is the implementation (terminal/TerminalLive.scala file):


package com.example.tictactoe.terminal

import kyo.*
import kyo.consoles.*
import kyo.ios.*
import kyo.envs.*
import kyo.layers.*

final class TerminalLive() extends Terminal:
  import TerminalLive.*

override val getUserInput: String < IOs = Consoles.readln

override def display(frame: String): Unit < IOs =  Consoles.print(ansiClearScreen).flatMap(_ => Consoles.println(frame))

object TerminalLive:
  final val ansiClearScreen = "\u001b[H\u001b[2J"

val layer: Layer[Envs[Terminal], Any] = Envs[Terminal].layer(TerminalLive())


In the case of getUserInput, we just need to read the user’s input from the console, so we can call the Consoles.readln method.


For the display method, we need to do two things sequentially:

  • Clear the screen: We can do that by printing the ANSI clear screen sequence to the console, by calling the Consoles.print method.

  • Then, we need to print the given frame. We can use the Consoles.println method, which is similar to Consoles.print but adds a new line at the end, as you would expect by the method’s name.

Now, for combining those two effects sequentially, we are using the flatMap method from Kyo. This is the same idea as in other effect libraries like ZIO: flatMap expects a function which maps the output of the first effect to another effect. For the display case, we don’t care about the output of Consoles.print(ansiClearScreen) because it’s just Unit, so we ignore it in the flatMap call.


By the way, since we know now that Kyo supports map and flatMap, we could also write display using a for-comprehension instead:


override def display(frame: String): Unit < IOs =
  for
    _ <- Consoles.print(ansiClearScreen)
    _ <- Consoles.println(frame)
  yield ()

We are using map to sequence two effects! That’s something unexpected because in ZIO, for example, the result would be a nested effect like UIO[UIO[Unit]]; but in Kyo that’s not a problem, because it turns out that map is just an alias for flatMap: Every time you call map on a Kyo value with a function A => B, what happens under the hood is that Kyo calls flatMap with a function from A => B < Any.


Finally, there’s an even better way to write the display method: Since in the second effect we are just discarding the output from the first one, we can combine both effects by using andThen (which would be the equivalent of ZIO’s zipRight):


override def display(frame: String): Unit < IOs =
  Consoles.print(ansiClearScreen).map(_ => Consoles.println(frame))

Writing the GameMode Service

This service’s interface is defined in the mode/game/GameMode.scala file:


package com.example.tictactoe.mode.game

import com.example.tictactoe.domain.State
import kyo.*
import kyo.ios.*

trait GameMode: 
 def process(input: String, state: State.Game): State < IOs 
 def render(state: State.Game): String

And the corresponding implementation in the mode/game/GameModeLive.scala file. Let’s analyze in detail the takeField method, which is used under the hood by process:


private def takeField(field: Field, state: State.Game): State.Game < IOsval logic: State.Game < (Aborts[AppError] & IOs) = 
   for    
  updatedBoard  <- gameLogic.putPiece(state.board, field, state.turn)      
  updatedResult <- gameLogic.gameResult(updatedBoard) 
  updatedTurn    = gameLogic.nextTurn(state.turn)   
 yield state.copy(     
  board = updatedBoard,   
  result = updatedResult,     
  turn = updatedTurn,   
  footerMessage = GameFooterMessage.Empty val logicEither: Either[AppError, State.Game] < IOs = Aborts[AppError].run(logic)

  logicEither.map {    
case Right(newState) => newState   
case Left(_)         => state.copy(footerMessage = GameFooterMessage.FieldOccupied) 
 }

As you can see, there’s an internal logic value which:

  • Succeeds with a new State.Game, which will be the new game state after the user puts a piece on the board

  • Can fail with an AppError

  • Performs IO

Next, we want to handle the error case on logic. To do that we will need to run the pending Aborts[AppError] effect, by calling Aborts[AppError].run(logic). This returns a new logicEither effect which:

  • Succeeds with an Either[AppError, State.Game]

  • Performs IO

And, because takeField should return a State.Game < IOs, we call map on logicEither to do the corresponding transformation.

Finally, let’s see analyze how the corresponding Layer is created in the companion object:


object GameModeLive:  
val layer: Layer[
    Envs[GameMode],
    Envs[GameCommandParser] & Envs[GameView] & Envs[OpponentAi] & Envs[GameLogic]
  ] =    Envs[GameMode].layer {     
 for       
gameCommandParser <- Envs[GameCommandParser].get    
gameView          <- Envs[GameView].get        
opponentAi        <- Envs[OpponentAi].get        
gameLogic         <- Envs[GameLogic].get      
yield GameModeLive(gameCommandParser, gameView, opponentAi, gameLogic)    
}

In this case, the construction of  layer is not as simple as for previous services, because GameModeLive depends on other services. So, basically, we provide a Kyo value that obtains those dependencies from the environment by calling Envs[SomeDependency].get and succeeds with a new instance of GameModeLive. The type of the resulting Layer reflects the corresponding dependencies: Layer[Envs[GameMode], Envs[GameCommandParser] & Envs[GameView] & Envs[OpponentAi] & Envs[GameLogic]].


Writing the ConfirmMode Service

This service’s interface is the following (mode/confirm/ConfirmMode.scala file):


package com.example.tictactoe.mode.confirm

import com.example.tictactoe.domain.State

trait ConfirmModedef process(input: String, state: State.Confirm): State  
 def render(state: State.Confirm): String

And the corresponding implementation is located in the mode/confirm/ConfirmModeLive.scala file. Let’s analyze the process method:


def process(input: String, state: State.Confirm): Stateval nextState: State < Aborts[AppError] =    
  confirmCommandParser      
   .parse(input)      
   .map {        
     case ConfirmCommand.Yes => state.confirmed        
     case ConfirmCommand.No  => state.declined     
 }  
Aborts[AppError]    
  .run(nextState)    
  .pure match    
  case Right(nextState) => nextState    
  case Left(_)          => 
   state.copy(footerMessage = ConfirmFooterMessage.InvalidCommand)

You can see we have a nextState effect, which:

  • Can succeed with a State value

  • Or, it can fail with an AppError


To handle the error case, we need to run nextState, by calling Aborts[AppError].run(nextState). This will give as a new Kyo value with type Either[AppError, State] < Any, which means:


  • It succeeds with an Either[AppError, State]

  • There’s no pending effects to handle. Because of this, we can call the pure method which will give us just an Either[AppError, State] which we can process directly.

Finally, we have the corresponding Layer in the companion object, which is built very similarly to GameModeLive.layer:


object ConfirmModeLive:  
 val layer: Layer[Envs[ConfirmMode], Envs[ConfirmCommandParser] & Envs[ConfirmView]] =
  Envs[ConfirmMode].layer {      
  for        
     confirmCommandParser <- Envs[ConfirmCommandParser].get   
     confirmView          <- Envs[ConfirmView].get      
yield ConfirmModeLive(confirmCommandParser, confirmView)    
}

Putting everything together in the TicTacToe main object

The TicTacToe object is the entry point of our application:


object TicTacToe extends KyoApp:
val program: State < (Envs[Terminal] & Envs[Controller] & IOs) = …
val layer: Layer[Envs[Controller] & Envs[Terminal], Any] = …
run {  
layer.run(program)}

Some important things to mention:


  • TicTacToe extends KyoApp, which gives us a run method which:

  • Expects a Kyo value containing the logic of our application

  • Is capable of handling Kyo’s default effects, such as IOs

  • The run method is not able to handle effects such as Options, Aborts or Envs, so we must handle those ourselves. We can see our application logic is contained in the program value, whose type is State < (Envs[Terminal] & Envs[Controller] & IOs), that means we need to handle Envs[Terminal] & Envs[Controller], and we do that by providing program to layer.run. We’ll analyze how this layer is constructed in a bit, but looking at its type (Layer[Envs[Controller] & Envs[Terminal], Any]) we can see it’s a Layer that, when run, gives us a Kyo value with no pending Envs effects, which is precisely what we want.


Let’s see now in detail how program is implemented:


val program: State < (Envs[Terminal] & Envs[Controller] & IOs) =  
 def step(state: State): Option[State] < (Envs[Terminal] & Envs[Controller] & IOs) =    
  val nextState: State < (Envs[Terminal] & Envs[Controller] & IOs & Options) =     
 for        
    terminal   <- Envs[Terminal].get        
    controller <- Envs[Controller].get      
   _           <- terminal.display(controller.render(state))        
    input      <- if state == State.Shutdown then "" else terminal.getUserInput                      
    nextStae    <- controller.process(input, state)
  yield nextState   

 Options.run(nextState) 

def loop(state: State): State < (Envs[Terminal] & Envs[Controller] & IOs) = …    
loop(State.initial)

Internally, there’s a step method, which simply:


  • Takes the current State of the game

  • Defines a nextState value, with type State < (Envs[Terminal] & Envs[Controller] & IOs & Options), so it’s a Kyo value that requires Terminal and Controller from the environment, and that’s happening because we are calling Envs[Terminal].get and Envs[Controller].get. The other pending effects (IOs and Options) come from terminal.display, terminal.getUserInput and controller.process calls

  • Returns an Option[State] < (Envs[Terminal] & Envs[Controller] & IOs), and that’s why we are calling Options.run(nextState). This way, we know that if the result is:

  • Some[State], the game should continue

  • None, the game is over.


Next, we have a loop method, which just calls step recursively:


def loop(state: State): State < (Envs[Terminal] & Envs[Controller] & IOs) =  
 step(state).map {    
   case Some(nextState) => loop(nextState)    
   case None            => state  
}

As you can see the implementation is straightforward. However, because this is a recursive method, we need to be careful; as the Kyo documentation states: Kyo evaluates pure computations strictly, without the need for suspensions or extra memory allocations. This approach enhances performance but requires careful handling of recursive computations to maintain stack safety. Given this characteristic, recursive computations need to introduce an effect suspension, like IOs, to ensure the evaluation is stack safe.

So, following this advice, we get:


def loop(state: State): State < (Envs[Terminal] & Envs[Controller] & IOs) =
  IOs {    step(state).map { 
     case Some(nextState) => loop(nextState)      
     case None            => state    
   }
 }

Finally, let’s see in detail how our application layer is constructed. Let’s take a look again to our design diagram:





Just by looking at the bottom of the diagram, we can see there are some opportunities for doing horizontal composition:


  • ConfirmCommandParser and ConfirmView

  • MenuCommandParser and MenuView

  • GameCommandParser, GameView, GameLogic and OpponentAi

 

So, we have the following in code:


val confirmModeDeps: Layer[Envs[ConfirmCommandParser] & Envs[ConfirmView], Any] =  
ConfirmCommandParserLive.layer andThen ConfirmViewLive.layer

val menuModeDeps: Layer[Envs[MenuCommandParser] & Envs[MenuView], Any] =  MenuCommandParserLive.layer andThen MenuViewLive.layer

val gameModeDeps: Layer[
  Envs[GameCommandParser] & Envs[GameView] & Envs[GameLogic] & Envs[OpponentAi], Any
] = 
  GameCommandParserLive.layer andThen GameViewLive.layer andThen
    GameLogicLive.layer andThen OpponentAiLive.layer

Seeing this graphically:



Now we can collapse one level by applying vertical composition at the bottom:


val confirmModeNoDeps: Layer[Envs[ConfirmMode], Any] = 
 ConfirmModeLive.layer chain confirmModeDeps
val menuModeNoDeps: Layer[Envs[MenuMode], Any] =  
  MenuModeLive.layer chain menuModeDeps
val gameModeNoDeps: Layer[Envs[GameMode], Any] = 
  GameModeLive.layer chain gameModeDeps

So we have:





Next, we can apply both horizontal and vertical composition once more at the bottom of the diagram:


val controllerNoDeps: Layer[Envs[Controller], Any] =  
  ControllerLive.layer chain (
    confirmModeNoDeps andThen menuModeNoDeps andThen gameModeNoDeps
 )

Graphically we have:





So, we can finish with horizontal composition, which gives us our final layer:


val layer: Layer[Envs[Controller] & Envs[Terminal], Any] =
controllerNoDeps andThen TerminalLive.layer 

Using direct syntax

Before finishing this article, let’s explore Kyo’s direct syntax. We can read the following in the documentation: “Kyo provides direct syntax for a more intuitive and concise way to express computations, especially when dealing with multiple effects. This syntax leverages two primary constructs: defer and await”.


In order to use Kyo’s direct syntax, we need to add the following dependency to our build.sbt file:


"io.getkyo" %% "kyo-direct" % kyoVersion

Let’s see an example by revisiting the step method inside TicTacToe.program:


def step(state: State): Option[State] < (Envs[Terminal] & Envs[Controller] & IOs) =  
 val nextState: State < (Envs[Terminal] & Envs[Controller] & IOs & Options) =   
  for     
    terminal   <- Envs[Terminal].get      
    controller <- Envs[Controller].get   
    _          <- terminal.display(controller.render(state))      
    input      <- if state == State.Shutdown then "" else terminal.getUserInput      
    nextState  <- controller.process(input, state)   
 yield nextState 
Options.run(nextState)

We can replace this with a more direct syntax, like this:


import kyo.direct.*

def step(state: State): Option[State] < (Envs[Terminal] & Envs[Controller] & IOs) =  
 val nextState: State < (Envs[Terminal] & Envs[Controller] & IOs & Options) =
   defer {   
     val terminal   = await(Envs[Terminal].get)      
     val controller = await(Envs[Controller].get)             
     await (terminal.display(controller.render(state)))      
     val input      = if state == State.Shutdown then "" else await(terminal.getUserInput)      
     await(controller.process(input, state))
    }  

Options.run(nextState)

This looks more like ordinary code, right? So, when you want to use direct syntax, you must open a defer block. And inside the defer block, every time you have a Kyo value, you must enclose it inside an await call, which gives you access to the effect’s success value. Some important points here:


  • The await method can be used inside a defer block only

  • Calling await is not the same as calling run on an effect. Remember that await is just syntax sugar that gives you access to an effect’s success value, but it’s not actually running the effect.


One nice thing about kyo-direct is that, if you have a Kyo value inside a defer block, but you don’t enclose it inside await, you will get a nice compilation error saying: Effectful computation must be inside an `await` block. This is for hygiene purposes and it ensures all effects are explicitly processed, reducing the potential for effects that are not actually used.


Summary

In this article, we have learned how to use the Kyo library; with its novel approach based on algebraic effects to create super performant and composable applications using pure functional programming. We have also seen what are the similarities and differences compared to other libraries like ZIO, and what the developer experience feels like. By the way; it’s great that, if you already know ZIO, several concepts you’ve learned transfer very nicely.


Also, we have learned how to use Layers in Kyo, a concept derived from ZIO which allows us to modularize our applications and to have a dependency injection mechanism. At the time of writing this article, there’s no way to automatically combine Layers like in ZIO though, so you have to do that manually for now.


I hope this article was useful to you, and that you give this library a try in the near future!


References


ความคิดเห็น


bottom of page