Introduction

In the previous post, we set up a new Scala project and made sure everything was working. Now, let’s start building our game by defining the model.

We’ll start with the most basic Hero model and then explore how we can leverage Scala’s powerful type system and libraries to make our code more type-safe.

Case class

We can start with a simple case class to represent our hero. A case class is a special type of class in Scala that is used to represent immutable data.

Let’s create a Hero class:

case class Hero(
  id: String,
  name: String,
  level: Int
)

And let’s try to create a hero:

def main(): Unit =
  val hero = Hero("1", "Hero", 0)
  println(hero) // Hero(1,Hero,0)

It looks good, but nothing prevents us from creating the following hero:

def main(): Unit =
  val hero = Hero("Hero", "1", -1)
  println(hero) // Hero(Hero,1,-1)

We ended up with ID Hero, the name 1, and negative level! We don’t want to make that possible. We need to make invalid states unrepresentable.

Dependencies

We’ll add our first library - iron . It’ll allow us to define types with very strict constraints.

To keep your IntelliJ project in sync, enable Auto reload or manually sync sbt changes .

To add the first dependency, create a new file project/Dependencies.scala with the following code:

import sbt.*

object Dependencies {
  private object Version {
    val iron = "3.0.0"
  }

  lazy val iron: ModuleID = "io.github.iltotore" %% "iron" % Version.iron
}

Then, update build.sbt:

lazy val dependencies = {
  Seq(Dependencies.iron)
}

lazy val root = (project in file("."))
  .settings(
    name             := "rpg",
    idePackagePrefix := Some("dev.kamgy"),
    libraryDependencies ++= dependencies
  )

Iron

Let’s say that we want to make sure that:

  • the Hero ID is a UUID
  • the Hero name must be alphanumeric, 5 to 32 characters long
  • the Hero level is a positive integer

Let’s make our Hero model more strict. This is a big step. If you’re new to refined types , it might take a moment to wrap your head around them.

Create a new file scala/hero/model/HeroModel.scala and add the following code:

package dev.kamgy
package hero.model

import java.util.UUID

import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.collection.MinLength
import io.github.iltotore.iron.constraint.numeric.Positive
import io.github.iltotore.iron.constraint.string.Alphanumeric

object HeroModel {
  type HeroId = HeroId.T
  object HeroId extends RefinedType[UUID, Pure] {
    def generate(): HeroId = HeroId.applyUnsafe(UUID.randomUUID())
  }

  type HeroName = HeroName.T
  object HeroName extends RefinedType[String, Alphanumeric & MinLength[5] & MaxLength[32]]

  type HeroLevel = HeroLevel.T
  object HeroLevel extends RefinedType[Int, Positive]
}

Then, create a new file scala/hero/model/Hero.scala and add the following code:

package dev.kamgy
package hero.model

import hero.model.HeroModel.*

case class Hero(
  id: HeroId,
  name: HeroName,
  level: HeroLevel
)

Let’s do a quick experiment in main.scala:

package dev.kamgy

import hero.model.Hero
import hero.model.HeroModel.*

@main
def main(): Unit =
  val hero = Hero(HeroId.generate(), HeroName("SuperHero"), HeroLevel(21))
  println(hero) // Hero(1440c791-d92a-4596-8b73-2e5579fea403,SuperHero,21)

This should work fine.

Now, let’s try to create a hero with an invalid name:

def main(): Unit =
  Hero(HeroId.generate(), HeroName("Bad"), HeroLevel(21))
  // Constraint Error: Should be alphanumeric & Should have a minimum length of 5 & Should have a maximum length of 32

The name is invalid, and the compiler is complaining!

Let’s try to create a hero with a negative level:

def main(): Unit =
  Hero(HeroId.generate(), HeroName("SuperHero"), HeroLevel(-1))
  // Constraint Error: Should be strictly positive

Great job, we made our Hero model more strict and type-safe!

Summary

In the next post, we’ll build the testing setup and write our first model tests.

You can find the complete code for this post here .