Typesafe techniques

for a better sleep at night

System failures

Lifecycle of an error

Runtime error

 

Let me tell you a story

You are having a sweet dream at night

Suddenly your phone rings

 

Production system is down

You are trying to figure it out

By the sunrise you sort it out

 

You're trying to stay alive the next day

 

End of story

Compile time error

Another story

You're coding gently in your (home) office

Compiler error appears

You are trying to figure it out

Your mates are there to help

Because it's not the middle of the night

You sort it out

You can sleep well at night

End of story

Runtime

  • Happens when the program is already running
  • Can have significant consequences
  • Wakes you up at night

Compile

  • Happens as you code
  • No way to deploy broken code - it doesn't compile

Wrapping up

Compile

Runtime

<

Is it possible?

Yes

Typesafe techniques

For a better sleep at night

QA walks into a bar

class BeerOrder {
  quantity: String
}

QA orders:

  • “1”

  • “9999999”

  • “A beer”

  • “0”

  • “-3”

  • “0.99999”

  • “A pint of beer”

  • “Pomidor”

  • “”

  • null

class BeerOrder {
  quantity: Double
}

QA orders:

  • 1

  • 9999999

  • “A beer”

  • 0

  • -3

  • 0.99999

  • “A pint of beer”

  • “Pomidor”

  • “”

  • null

class BeerOrder {
  quantity: Int
}

QA orders:

  • 1

  • 9999999

  • “A beer”

  • 0

  • -3

  • 0.99999

  • “A pint of beer”

  • “Pomidor”

  • “”

  • null

class BeerOrder {
  quantity: PosInt
}

QA orders:

  • 1

  • 9999999

  • “A beer”

  • 0

  • -3

  • 0.99999

  • “A pint of beer”

  • “Pomidor”

  • “”

  • null

Other types

val uuid1: String Refined Uuid = "6736a2fb-0848-4eb7-a9f3-0556f384c952"
val uuid2: String Refined Uuid = "123"


val posint1: PosInt = 123
val posint2: PosInt = -10


val xml1: String Refined Xml = "<div>hello world</div>"
val xml2: String Refined Xml = "<div>hello /div>"


val even: Int Refined Even = 10
val odd: Int Refined Odd = 11

Real world example

case class UnsafeOrderLine(product: String, quantity: Int)

// Valid order line, we want those!
UnsafeOrderLine("123", 10)

// Wait that's illegal
UnsafeOrderLine("", 10)
UnsafeOrderLine("banana", -2)
// ☝️ this will cause runtime errors 😱

How would we fix it without refined?

Real world example

case class UnsafeOrderLine(product: String, quantity: Int)
object UnsafeOrderLine {
 def safeApply(product: String, quantity: Int): UnsafeOrderLine = {
   if(product.isEmpty())
     throw new RuntimeException("Product is empty")
   else if(quantity <= 0)
     throw new RuntimeException("Quantity lower than 1")
   else
     UnsafeOrderLine(product, quantity)
 }
}

// Works fine!
UnsafeOrderLine.safeApply("123", 10)
// Throws runtime exception 👇
UnsafeOrderLine.safeApply("", 10)

It works... kind of

Real world example

case class UnsafeOrderLine(product: String, quantity: Int)
object UnsafeOrderLine {
 def safeApply(product: String, quantity: Int): UnsafeOrderLine = {
   if(product.isEmpty())
     throw new RuntimeException("Product is empty")
   else if(quantity <= 0)
     throw new RuntimeException("Quantity lower than 1")
   else
     UnsafeOrderLine(product, quantity)
 }
}

Drawbacks

  • Compiler can't help us this way
  • Needs addtional tests
  • What about JSON decoding?

Let's do it better

import eu.timepit.refined.auto._
import eu.timepit.refined.types.string._
import eu.timepit.refined.types.numeric._

case class OrderLine(product: NonEmptyString, quantity: PosInt)
OrderLine("123", 10)  // Returns OrderLine
OrderLine("", 10)     // Doesn't compile

Advantages

  • Compile time check
  • No need to write tests
  • Clean code
  • Self documenting
  • Out of the box JSON support in Circe

Is that all?

Glad that you asked

Typesafe APIs

Say hello to Tapir

Let's build a trivial API

The domain

import cats.data.NonEmptyList
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.types.string._
import eu.timepit.refined.types.numeric._

case class OrderLine(product: NonEmptyString, quantity: PosInt)

case class Order(orderId: String Refined Uuid, lines: NonEmptyList[OrderLine])

Let's add JSON codecs

The domain

import cats.data.NonEmptyList
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.types.string._
import eu.timepit.refined.types.numeric._
import io.circe.Codec
import io.circe.generic.semiauto._
import io.circe.refined._

case class OrderLine(product: NonEmptyString, quantity: PosInt)

object OrderLine {
  implicit val codec: Codec[OrderLine] = deriveCodec
}

case class Order(orderId: String Refined Uuid, lines: NonEmptyList[OrderLine])

object Order {
  implicit val codec: Codec[Order] = deriveCodec
}

The endpoint

import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.generic.auto._

object OrderEndpoints {

  val validate: PublicEndpoint[Order, Unit, Unit, Any] =
    endpoint
      .post
      .in("validate") 	    // defines a path http://localhost:8080/validate
      .in(jsonBody[Order])  // takes Order as an input
      .out(jsonBody[Unit])  // the response content is always `{}`

}

Validation
endpoint

Order in JSON format

200 for success

400 for failure

The logic

import cats.effect._
import cats.implicits._

object OrderRoutes {

  val logic: Order => Unit = _ => () // define the logic

  // bind the endpoint to the logic
  val validate =
    OrderEndpoints.validate.serverLogic(logic(_).asRight[Unit].pure[IO])
}

Tapir does the validation based on the types, so the endpoint does nothing

The boring stuff

import cats.effect._
import org.http4s.HttpRoutes
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.server.Router
import sttp.tapir.server.http4s.Http4sServerInterpreter
import scala.concurrent.ExecutionContext

object Server {
  private val routes: HttpRoutes[IO] =
    Http4sServerInterpreter[IO]().toRoutes(
      OrderRoutes.validate // the thing from the previous slide ;)
    )

  val resource = BlazeServerBuilder[IO]
    .withExecutionContext(ExecutionContext.global)
    .bindHttp(8080, "localhost")
    .withHttpApp(Router("/" -> routes).orNotFound)
    .resource
}

object Main extends IOApp {
  override def run(args: List[String]): IO[ExitCode] = {
    Server.resource
      .useForever
      .as(ExitCode.Success)
  }
}

Let's wire the things together

The test

$ echo -n '{
  "orderId" : "17da8323-6e08-4519-aab6-ee0a9f9a30b3",
  "lines" : [
    {
      "product" : "product1",
      "quantity" : 10
    }
  ]
}
' | http POST localhost:8080/validate
HTTP/1.1 200 OK
Content-Length: 2
Content-Type: application/json
Date: Tue, 28 Jun 2022 10:44:10 GMT

{}

Let's see how it work

$ echo -n '{}' | http POST localhost:8080/validate 
HTTP/1.1 400 Bad Request
Content-Length: 96
Content-Type: text/plain; charset=UTF-8
Date: Tue, 28 Jun 2022 10:45:47 GMT

Invalid value for: body (Missing required field at 'orderId', Missing required field at 'lines')

The test

$ echo -n '{
  "orderId" : "17da8323-6e08-4519-aab6-ee0a9f9a30b3",
  "lines" : [
    {
      "product" : "product1",
      "quantity" : -1
    }
  ]
}
' | http POST localhost:8080/validate
HTTP/1.1 400 Bad Request
Content-Length: 76
Content-Type: text/plain; charset=UTF-8
Date: Tue, 28 Jun 2022 12:38:26 GMT

Invalid value for: body (Predicate failed: (-1 > 0). at 'lines[0].quantity')

Let's see how it work - negative quantity

The test

$ echo -n '{
  "orderId" : "17da8323-6e08-4519-aab6-ee0a9f9a30b3",
  "lines" : []
}
' | http POST localhost:8080/validate
HTTP/1.1 400 Bad Request
Content-Length: 100
Content-Type: text/plain; charset=UTF-8
Date: Tue, 28 Jun 2022 12:39:59 GMT

Invalid value for: body (Missing required field at 'lines[0]', Missing required field at 'lines[0]')

Let's see how it work - no lines

Pretty impressive for no logic, isn't it?

Bonus!

object Server {

  import sttp.tapir._
  import sttp.tapir.swagger.bundle.SwaggerInterpreter
  
  val myEndpoints: List[AnyEndpoint] = List(OrderEndpoints.validate)
  val swaggerEndpoints = SwaggerInterpreter().fromEndpoints[IO](myEndpoints, "My App", "1.0")

  private val routes: HttpRoutes[IO] =
    Http4sServerInterpreter[IO]().toRoutes(
      List(
        OrderRoutes.validate,
      ) ++ swaggerEndpoints
    )

  val resource = BlazeServerBuilder[IO]
    .withExecutionContext(ExecutionContext.global)
    .bindHttp(8080, "localhost")
    .withHttpApp(Router("/" -> routes).orNotFound)
    .resource

}

Open API docs for free

Summing up

  • Endpoint definition verified at compile time
  • Data validation for free
  • Easy to plug in the logic and verify if it handles the right kind of data
  • Seamless interop with ecosystem (CatsEffect, Circe, Refined, and others)
  • Example available at
    https://github.com/majk-p/typesystem-goodies

Is that all?

Of course not!

Other good stuff

Other resources

The end

Thank you!

Runtime errors and how to avoid them

By Michał

Runtime errors and how to avoid them

  • 266