Contents

Writing a simple CLI game in Scala 3

Krzysztof Atłasik

17 Feb 2022.22 minutes read

Writing a simple CLI game in Scala 3 webp image

After many years of waiting, Scala 3 was finally released in May 2021. Moreover, 2021 was also the year of release of the 3rd version of cats-effect, the very popular scala’s functional programming framework.

I do Scala programming for a living, but most of the codebase I work on daily is still using Scala 2.13 (or sometimes even 2.12). Most of the code I write is using a functional stack based on cats-effect, but again, I haven’t yet had a chance to migrate all the services to cats-effect 3.
Last weekend, I had a free evening, so I decided to write a simple console application for playing tic-tac-toe in Scala 3 and CE3.

My goals were:

  • Creating a CLI program that would allow playing tic-tac-toe for 2 players with a console.
  • The application should display the status of the game as an ASCII representation of the board.
  • Players should be able to pass coordinates of the fields as a combination of a single letter and a number, like A1, B2 etc.
    This is supposed to be an introductory tutorial for people new to Scala 3 and cats-effect, so I will try to explain everything I’m doing. On the other hand, this is my first “serious” Scala 3 application, so there might be some areas for improvement in my code. If you have any clues or tips for me, please feel free to leave a comment.

As an exercise, I tried to use as many Scala 3 enhancements and new features as I was able to figure out. I also try to follow functional-programming paradigms. I use helper methods and data types from the newest versions of cats (2.7.0 at the time this article was written) and cats-effect (version 3.3.1).

Starting up

I will use sbt as my build tool. After setting up a new project with sbt new scala/scala3.g8, I'd just need to add a few dependencies. To keep my game as simple as possible, I will restrict them to the absolute minimum and add just cats and cats-effect. My testing framework of choice is munit.

libraryDependencies += "org.typelevel" %% "cats-effect" % "3.3.1"
libraryDependencies += "org.typelevel" %% "cats-core" % "2.7.0"
libraryDependencies += "org.typelevel" %% "munit-cats-effect-3" % "1.0.7" % Test

I decided to try out the new braceless syntax of Scala 3, which I'm not yet very familiar with.
So as the next step, I will add support for scalafmt to help me with formatting. In order to do that, I just need to add a plugin in project/plugins.sbt:

addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")

Then I will need to create a simple config file .scalafmt.conf:

version = 3.3.0
runner.dialect = scala3

All the code snippets I show in the post are also available on GitHub, where you can find the whole code of the game.

Modelling basic domain

After the initial setup, I can start by modelling some domain objects. I’d need some way to represent players of our game. There will always be two of them, one playing with X and another with O symbols.

In Scala 2, I’d choose to model such enumeration with ADT.
I would create a sealed trait and then create two instances of case object.

sealed trait Player

case object X extends Player
case object O extends Player

Additionally, I would probably use an auxiliary library like enumeratum, that can automatically create a values list of all cases. This is the most common way of simulating missing enum features in Scala 2.

Scala 3 filled the gap and introduced a native way for encoding enumerations with the keyword enum:

enum Player:
 case X
 case O

Enums in Scala can have methods. I will add one for switching between players. It will just check if the player is X and then return O or otherwise:

def next: Player = this match
   case X => O
   case O => X

It’s also possible to create companion objects for enums just like for regular classes. Since I’m using cats, it would be handy to provide instances of Eq and Show typeclasses for Player.

In Scala 2, instances of type classes could be automatically derived using libraries like derevo, kittens or magnolia. Unfortunately, at the time I'm writing this article, these were not updated for Scala 3. Scala 3 has built-in support for creating derivation mechanisms for typeclasses. I could utilize it to create generic derivations of Eq and Show for all my classes and enums.

Both approaches seem to be overkill for such a simple application. Since I want to keep my game as simple as possible, I will just create instances for Eq and Show by hand using helper methods provided by cats.

object Player:
  given Show[Player] = Show.fromToString
  given Eq[Player] = Eq.fromUniversalEquals

As you might have noticed, implicit from Scala 2 is replaced with the new keyword given. Since we initialize given instances in a companion object of Player, they will always be in scope for the Player class (it worked similarly for implicits in Scala 2).

Great! Let’s create another enum that will represent the status of a particular field on the game board.

enum FieldStatus:
  case Empty
  case Taken(player: Player)

As you can see, unlike Java’s enums, Scala’s can have parameterized constructors. In this regard, it can be as flexible as hierarchical ADT in Scala 2.

Adding global variable

For the sake of simplicity, I want to hardcode the size of the board of my game to 3x3 fields. I will store the size in a constant called BoardSize. In Scala 2, I would either need to put that constant in a regular object (maybe called Globals) or I could use a package object. In Scala 3, it’s no longer necessary, I can make BoardSize a top-level definition in any file. All objects in the same package will have visibility of BoardSize without the need to import it.

val BoardSize = 3

Parsing user input

As a next step, I will create a case class called Coordinate. It will describe pair numbers representing the position of the field using the x and y-axis.

final case class Coordinate(x: Int, y: Int)

The users will be passing data as text like A1 or C3, so I would need some way to parse it.

Scala has a lot of amazing libraries for parsing text like fastparse or cats-parse, but I want to have as few dependencies as possible, so I will write my own parsing method.

A great place to add a utility function for parsing such strings would be the companion object of Coordinate.

object Coordinate:
   private val Letters = LazyList.from('A').take(BoardSize).toVector

   def parse(s: String): Option[Coordinate] =      

     def fromLetter(c: Char): Option[Int] = Letters.indexOf(c.toInt) match
       case -1 => None
       case x => Some(x)

     def fromDigit(s: Char): Option[Int] = Option
       .when(s.isDigit)(s.asDigit - 1)
       .filter(d => d >= 0 && d < BoardSize)

      s.toCharArray match
       case Array(letter, digit) => (fromLetter(letter), fromDigit(digit)).mapN(Coordinate.apply)
       case _ => None

The method parse converts the input string into an array of chars, then matches only arrays with two characters. The first character is parsed using the function fromLetter and the second one is parsed by fromDigit. Both of these functions return Option to indicate if they were able to successfully convert the raw input character.

Only if both of them succeed, can I create an instance of Coordinate case class. I use mapN from cats to convert tuple (Option[Int], Option[Int]) into Option[Coordinate]:

(fromLetter(letter), fromDigit(digit)).mapN(Coordinate.apply)

For formatting Coordinate into a string, I will create an instance of the Show typeclass and put it into the companion of Coordinate:

given Show[Coordinate] = Show.show(c =>
   Letters.get(c.x) match {
     case Some(letter) if c.y >= 0 && c.y < BoardSize =>
       s"${letter.toChar}${c.y + 1}"
     case _ => "--OUT OF BOUNDS--"
   }
 )

To find out if my implementation is working, I will create simple tests. There are many capable testing frameworks I could use, but I chose munit since it has great cats-effect integration. I also considered weaver, or cats-effect-testing with scalatest or specs2.

The implementation of the test for checking out parsing of the input string will go over a few cases:

test("should parse correct coordinates") {

  //when
  val cases = List(
    ("A1", Some(Coordinate(0, 0))),
    ("A3", Some(Coordinate(0, 2))),
    ("B1", Some(Coordinate(1, 0))),
    ("B2", Some(Coordinate(1, 1))),
    ("B3", Some(Coordinate(1, 2))),
    ("C1", Some(Coordinate(2, 0))),
    ("C3", Some(Coordinate(2, 2)))
   )

  //then
  cases.foreach {
    case (rawString, expected) =>
      assert(clue(Coordinate.parse(rawString)) == clue(expected))
  }
}

I also created some other tests for invalid input and for formatting Coordinate into a string. You can check them on GitHub.

Building the board

Another important building block of the game will be the Board. It will represent a 2D 3x3 board. The most obvious way to implement such a structure is a nested array containing instances of FieldStatus.

Instead of using a plain mutable Array, I will use ArraySeq. It’s just an immutable wrapper over an array introduced in Scala 2.13. It offers a functional API for operations like modifying or accessing its elements.

Additionally, since ArraySeq uses a plain array under-the-hood, it allows for efficient (with complexity O(1)) access to any random element.

To improve type safety and the ergonomy of my board interface, I could introduce an additional class Board, that would be just a wrapper over ArraySeq[ArraySeq[FieldStatus]].
Instead, I will use another feature of Scala 3: opaque types. I can declare Board as follows:

opaque type Board = ArraySeq[ArraySeq[FieldStatus]]

This way, the runtime type of the Board ArraySeq will still be nested, but during compilation, it will be treated as a completely new type Board. This allows for enhanced type safety with 0 runtime overhead!

Still, since outside the companion object of Board I wouldn’t be able to use methods of ArraySeq, my new opaque type will be pretty useless. I can enhance it with a full-fledged API by adding a new factory method for creating an empty board and adding extension methods that would allow accessing elements using Coordinate instances and for doing updates.

object Board:
  def create: Board = ArraySeq.fill(BoardSize)(ArraySeq.fill(BoardSize)(FieldStatus.Empty))

  extension (f: Board)
    def apply(c: Coordinate): Option[FieldStatus] = for
      line <- f.get(c.y)
      cell <- line.get(c.x)
    yield cell

    def update(c: Coordinate, fieldStatus: FieldStatus): Option[Board] =
      import c.{x, y}
      for
        line <- f.get(y)
      yield f.updated(y, line.updated(x, fieldStatus))

Extensions is another mechanism introduced in Scala 3. It's a replacement for Scala 2 implicit classes. It is used for enhancing types we don’t own (for example that were defined in the standard library). Since Board is basically just ArraySeq, the only way to provide our own methods for it is by extensions.

Similarly, as with other classes, I will provide an instance of Show for Board:

given Show[Board] with
   def show(f: Board) =
     val numbers = LazyList.from(1).take(f.size).map(String.format("%1$2d", _))
     val letters = LazyList.from('A').take(f.size).map(_.toChar.toString)

     ("   " + letters.mkString(" ") + "\n") + f.zip(numbers).map {
       case (line, number) =>
         (number + " ") + line.map {
           case FieldStatus.Empty => " "
           case FieldStatus.Taken(Player.X) => "X"
           case FieldStatus.Taken(Player.O) => "O"
         }.mkString("|")
     }.mkString("\n   " + "- " * f.size + "\n")

The result of printing the board with that Show instance should look like:

   A B C
 1 X| |
   - - -
 2  | |
   - - -
 3  |O|

Adding errors

In order to implement the engine of our game, we’d need to define some errors first.
We can create an enum that will extend the Throwable thus creating a very clean exception hierarchy:

import cats.syntax.all._

enum GameError(msg: String) extends Throwable(msg):
  case FieldAlreadyTaken(c: Coordinate, p: Player) extends GameError(show"The field $c is already taken by $p.")
  case WrongPlayer(c: Coordinate, p: Player) extends GameError(show"Wrong player: $p.")
  case CoordinateOutOfBound(c: Coordinate) extends GameError(show"Coordinates are out of bounds: $c (${c.x}, ${c.y}).")
  case GameIsOver(winner: Option[Player]) extends GameError(winner.fold("Game was drawn")(p => show"Game was already won by $p."))

For creating error messages, I used the show interpolator from cats. Its main advantage is that it uses the Show instance to create a string representation of the object instead of calling toString.

Assembling the game board

Now let’s create a model for a game. It will need to hold a state of the game: which player’s turn it is. It will also use Board to store information on which fields are empty and which are already taken by one of the players.

Let’s start with creating an enum that would define the state of the game:

enum GameStatus:
  case Ongoing(nextPlayer: Player)
  case Won(winner: Player)
  case Drawn

Now it’s time for a case class:

final case class Game(fields: Board, status: GameStatus, moves: Int)

I added an additional field moves to easily track how many moves were already done by players.

First, let’s create a factory method for creating a new empty board:

def create: Game = Game(Board.create, GameStatus.Ongoing(Player.X), 0)

The method move will describe the state transition of the Game object after receiving two pieces of information: which player is making a move and the coordinate of the target field.
Depending on its internal state and arguments, it can either succeed and return an updated instance of Game or fail.

There are many possible failure scenarios: attempt to make a move by a wrong player, attempt to take a non-empty field or just doing a move on a game that is already finished.
I will use the Either datatype to represent the partial nature of the function. For failing cases, I will return the Left subtype with an instance of specific GameError. Otherwise, the function will return Right with the updated Game.

def move(c: Coordinate, p: Player): Either[GameError, Game] =
   status match
     case GameStatus.Won(winner) => GameError.GameIsOver(winner.some).asLeft
     case GameStatus.Drawn           => GameError.GameIsOver(none).asLeft
     case GameStatus.Ongoing(nextPlayer) =>
       fields(c) match
         case Some(FieldStatus.Empty) =>
           if nextPlayer === p then
             fields
               .update(c, FieldStatus.Taken(p))
               .map(updated =>
                 process(copy(fields = updated, moves = moves + 1), p, c)
               )
               .toRight(GameError.CoordinateOutOfBound(c))
           else GameError.WrongPlayer(c, p).asLeft
         case Some(FieldStatus.Taken(_)) =>
           GameError.FieldAlreadyTaken(c, p).asLeft
         case None => GameError.CoordinateOutOfBound(c).asLeft

What is left to implement here is the process function (not the best name, but I couldn’t figure out better for now). Its purpose would be to get the Game instances updated with a new move and figure out a new state of the game. It would need to check if the game is drawn, won by any players or if it can just continue.

To check if a game was won by any of the players, I would need to test if any horizontal, vertical or diagonal row contains three marks of any particular player.

I can use the following algorithm: I will start with the coordinate of the last applied move and then go horizontally (go left and right), vertically (up and down) and on diagonals (from top left to the bottom right and from top right to bottom left) and count fields until I spot an empty field or a field taken by another player. It sounds easy so far.

My first step will be creating a type representing a translation of coordinate to its neighbouring coordinate. I decided to use a tuple of 2 integers (the first one represents a move on the x-axis and the other one on the y-axis). The range of Int in JVM is -2^31 i 2^31 -1 and I just need -1 (to go left or up), 0 (to leave coordinate is the same place) or +1 (to go down or right).

Thankfully Scala 3 again offers a solution. I can represent Direction as a tuple of two union types containing 3 singleton types (for -1, 0 and 1):

type Direction = (-1 | 0 | 1, -1 | 0 | 1)

Then I will add constants for all directions including diagonals. For example, T will represent getting one square up the board.

object Direction:
 // left
 val L: Direction = (-1, 0)
 // right
 val R: Direction = (1, 0)

 // top
 val T: Direction = (0, -1)
 // bottom
 val B: Direction = (0, 1)

 // left top
 val LT: Direction = (-1, -1)
 // right top
 val RT: Direction = (1, -1)

 // left bottom
 val LB: Direction = (-1, 1)
 // right bottom
 val RB: Direction = (1, 1)

The method translate in Coordinate will take Direction and apply translation returning a new instance of Coordinate. It returns Option since it can fail in case a new coordinate will be outside of the board. For example, if I try to apply RB on a coordinate that is on the right edge of the board, it should return None.

def translate(d: Direction): Option[Coordinate] = d.bimap(_ + x, _ + y) match
   case (nx, ny) =>
     Option.when(nx >= 0 && nx < BoardSize && ny >= 0 && ny < BoardSize)(
       Coordinate(nx, ny)
     )

Now I can implement process. It has a recursive helper function count that counts fields in any direction until it encounters a field that is doesn’t belong to a player. The function is annotated with @tailrec to make sure it’s tail-recursive and stack-safe. It doesn’t really matter in our case, because we won’t blow up the stack by checking boards of size 3x3, so I do it just as a good practice.

Next, I create a list of checks for all the required directions to get the number of consecutive fields taken by a player that just made a move. If any of the elements of the list is equal to the BoardSize, it means the player was able to mark the whole line of 3 and the game is over.

If this is not the case, but there are already 9 moves (BoardSize squared) the game is drawn.

In other cases, the game can continue.

private def process(
     board: Game,
     player: Player,
     coordinate: Coordinate
): Game =

   // Goes in the single direction as long as it finds fields taken by the player.
   // If bounds of the board or empty or other player's field is found, it returns the score.
   @tailrec
   def count(direction: Direction, c: Coordinate, score: Int): Int =
     c.translate(direction) match
       case Some(next) =>
         board.fields(next) match
           case Some(FieldStatus.Taken(`player`)) =>
             count(direction, next, score + 1)
           case _ => score
       case _ => score

   val victory = List(
     // horizontal
     count(Direction.L, coordinate, 0) + count(Direction.R, coordinate, 0),
     // from top left to bottom right
     count(Direction.LT, coordinate, 0) + count(Direction.RB, coordinate, 0),
     // from bottom left to top right
     count(Direction.LB, coordinate, 0) + count(Direction.RT, coordinate, 0),
     // vertical
     count(Direction.B, coordinate, 0) + count(Direction.T, coordinate, 0)
   ).exists(_ + 1 >= BoardSize)

   if victory then board.copy(status = GameStatus.Won(player))
   else if board.moves === (BoardSize * BoardSize) then board.copy(status = GameStatus.Drawn)
   else board.copy(status = GameStatus.Ongoing(player.next))

The function process could be optimized in many ways (for example by doing checks in parallel), but I guess it’s good enough for my POC.

Now I will write a test to check my implementation. I will add an extension helper function moveMultiple that will be visible only for tests and will allow me to easily apply sequence moves to a Game instance.

extension (b: Game)
   def moveMultiple(moves: (String, Player)*): Either[GameError, Game] =
     moves.foldLeft(b.asRight) {
       case (board, (move, player)) =>
         val coordinate = Coordinate.parse(move).getOrElse(fail(s"Wrong coordinate: $move !"))
         board.flatMap(_.move(coordinate, player))
     }

It just takes a vararg of tuples containing the move and the player and then applies them sequentially on the game instance.

Example test for checking if the game is correctly drawn could look like that:

test("should allow playing whole game and drawing") {
   //given
   val board = Game.create

   //when
   val Right(result) = board.moveMultiple(
       ("A1", Player.X),
       ("A2", Player.O),
       ("A3", Player.X),
       ("B1", Player.O),
       ("B3", Player.X),
       ("B2", Player.O),
       ("C2", Player.X),
       ("C3", Player.O),
       ("C1", Player.X)
     ) 

   //then
   assert(clue(result.status) == GameStatus.Drawn)
}

You can check other tests for Game on GitHub.

At this point, I’ve got a working “engine” of my application and I can programmatically play the whole game of tic-tac-toe.

Creating a runtime

Until this point, my app didn’t interact with the outside world. At this moment, I would like to add some side effects: for reading the user's input from the console and for printing out messages and state of the board.

First, I will start with creating a trait that will define actions for both reading and writing:

import cats.effect.IO

trait Console:
 def readLine: IO[String]
 def printLine(s: String): IO[Unit]

Both actions are performing side-effects by interacting with the system console.
The functional approach for handling side-effecting code is using an effect-system like IO monad from cats-effect. The IO monad suspends execution of side-effecting action. Such suspended actions are pure values that can be composed together creating a bigger and bigger chained IO. The composed program then can run and execute all suspended side-effects.

The class GameRuntime will be responsible for handling all game flow. It will be using actions from the console to compose a bigger functional program. Thus all methods in GameRuntime will return IO as well. The instance of Console will be passed in the constructor to GameRuntime.

final class GameRuntime(console: Console):

The first method that I will define is readLoop. Its purpose is to ask the player to enter the coordinates of their move and read their input from the console. The entered string is then parsed by the method Coordinates.parse, which returns Some if it was able to decode coordinates and None otherwise. If parsing fails, the function asks the user to enter correct coordinates and then calls itself recursively. This way, it effectively loops until the user provides valid input. If coordinates are parsed successfully, the function just returns them.
To compose IO, we can use nested flatMap calls or use for-comprehension, which is just syntax sugar for chained flatMaps.

private def readLoop(nextPlayer: Player): IO[Coordinate] = for
   _ <- console.printLine(show"Player $nextPlayer please make a move: ")
   line <- console.readLine
   result <- Coordinate.parse(line) match
     case Some(result) => result.pure[IO] // pure just wraps pure value into context of IO
     case None =>
       console.printLine("Please enter correct coordinate:") >> readLoop(nextPlayer)
 yield result

You might notice that the recursive call of readLoop is placed after the >> operator. In essence, it is just a shortcut for flatMap. Another very similar operator is *>. You can read more about the differences between these two here.

Another interesting detail is that readLoop is not tail-recursive. Wouldn’t it cause a stack overflow in case a user attempts to enter illegal input too many times? The answer is no because the IO monad is tramopolined and can safely be used in recursive functions without even being tail-recursive.

Let's now write the function that will bind everything together. I will call it loop. It will be responsible for printing the state of the board, getting inputs from players, and displaying the winner if the game is over (or informing about the draw).

private def loop(game: Game): IO[Unit] = for
   _ <- console.printLine(show"\n${game.fields}\n")
   _ <- game.status match
     case GameStatus.Drawn => console.printLine("\nDraw!\n")
     case GameStatus.Won(player) =>
       console.printLine(show"\nPlayer ${player} won the game!\n")
     case GameStatus.Ongoing(nextPlayer) =>
       for
         _ <- console.printLine("")
         move <- readLoop(nextPlayer)
         _ <- game.move(move, nextPlayer) match
           case Right(updated) => loop(updated)
           case Left(error) =>
             console.printLine(error.getMessage) >> loop(game)
       yield ()
 yield ()

Method loops calls itself recursively until the game is over. Since loop is composing Console and other methods returning IO, its result type will be also IO.

Now let’s write the last utility methods that will be the public entry point of GameRuntime.

val run: IO[Unit] =
   console.printLine("\n-- Starting a new game --\n") >> loop(Game.create)

It displays information about a new game starting and then creates a Game instance and passes it to loop.

Now I need to create tests for GameRuntime. In order to be able to pass coordinates to the game and do any assertions on the printed output, I’d need to capture text lines written to or read by the console. Alternatively, since all IO actions are encapsulated in trait Console, I would just need to provide a mocked implementation of this trait.

The mock will have two internal lists, the first one for lines that are read for the console and the other one for those that are written. The application, by reading lines from the console, will be removing values from the first list. If it attempts to read a line when the list is empty, it will fail the test. On the other hand, it will just append a new line to the second list every time an application writes something to the console. It will make it possible to do assertions to check the output of the application.

import cats.effect.kernel.Ref
import cats.effect.IO
import munit.Assertions._

class TestConsole private (
   inputs: Ref[IO, List[String]],
   outputs: Ref[IO, List[String]]
) extends Console:

 def printedLines: IO[List[String]] = outputs.get.map(_.reverse)

 override def readLine: IO[String] =
   inputs.modify {
     case x :: xs => (xs, x)
     case Nil     => fail("No more input strings for test console!")
   }

 override def printLine(s: String): IO[Unit] = outputs.update(xs => s :: xs)

object TestConsole:

 def create(inputs: String*): IO[TestConsole] = for
   inputs <- Ref.of[IO, List[String]](inputs.toList)
   outputs <- Ref.of[IO, List[String]](Nil)
 yield TestConsole(inputs, outputs)

My mock console is using Ref to allow for effectful mutations of lists. If I used tagless final in my game’s code, I could also use the State monad to handle state changes (modifications of both lists).

Now I can create the first test. It will check the scenario when player X wins:

test("should allow playing the whole game by player X") {

   val VictoryX = List(
   "A1",
   "B1",
   "A2",
   "B2",
   "A3"
 )

   for
     console <- TestConsole.create(VictoryX*)
     _ <- GameRuntime(console).run
     lines <- console.printedLines
   yield assert(clue(lines.lastOption).contains("\nPlayer X won the game!\n"))
 }

Great! There are more tests for various scenarios. You can check them on GitHub.

As you can see, with the simple OOP trick of inversions of control, I was able to easily test the application performing external side effects. I just delegated all side-effecting actions to the interface (trait) and then provided a mocked implementation for tests.

Final steps

The last thing I need to do is create a runnable application. First, I will need to provide a real implementation of the Console trait. It will simply delegate the read action to IO.readLine and write action to IO.println.

object LiveConsole extends Console:
 override def readLine: IO[String] = IO.readLine
 override def printLine(s: String): IO[Unit] = IO.println(s)

Then I will create a class Main that will extend IOApp.Simple from cats-effect. I need to override the method run that returns IO[Unit]. It is an IO that describes my whole application.

To start a new game, I must call a run from GameRuntime. I then will use the foreverM combinator to loop my application forever. Whenever the game ends, it will start another one right away.

object Main extends IOApp.Simple:

 override val run = GameRuntime(LiveConsole).run.foreverM

Now, let’s try it. I just need to type sbt run and I should be able to play the game:

Everything seems to be working fine!

Wrapping up

I hope you will find this short tutorial helpful, especially if you’re considering adopting Scala 3 and CE3 in your codebase.

It was definitely a lot of fun for me to use all these new features. I look forward to migrating the services I maintain to the newest version of Scala.

Curious to know how developers feel about Scala 3? Download the full version of the Scala Tech Report here.

Blog Comments powered by Disqus.