DEV Community

Cover image for Scala for Haskell developers
Zelenya
Zelenya

Posted on

Scala for Haskell developers

So, you have some Haskell experience and want (or need to) write Scala code. The following is a rough map with the main differences, similarities, major gotchas, and most useful resources.

đŸ“č Context: subjective-production-backend experience, web apps, microservices, shuffling jsons, and all that stuff.

đŸ„ˆÂ Scala 2 and Scala 3. In 2024, some projects still haven’t upgraded to Scala 3. Unless it includes the company you’re working for, you don’t need to worry about Scala 2.

Most of the stuff here applies to both; we lean towards Scala 3 and explicitly describe both if there is a big difference.


đŸ“č Hate reading articles? Check out the complementary video, which covers the same content.

Basic syntax and attitude

You should pick up syntax by yourself, but here are the things you should conquer first:

Driving on the wrong side of the road

Forget about reading code from right to left. You’re not in Haskell land.

-- read from right to left
foo err = pure $ Left $ errorMessage err
Enter fullscreen mode Exit fullscreen mode

Scala, similar to Java, often chains from left to right:

// read from left to right
def foo(err: Error) = err.errorMessage().asLeft.pure[IO]
Enter fullscreen mode Exit fullscreen mode

At the same time, you will often encounter code like this:

def fee(err: Error) = (Left(err.errorMessage())).pure[IO]

def faa(err: Error) = IO.delay(Left(err.errorMessage()))
Enter fullscreen mode Exit fullscreen mode

The order and syntax are different due to using methods and not functions.

Methods

def fee(err: Error) = (Left(err.errorMessage())).pure[IO]

def faa(err: Error) = IO.delay(Left(err.errorMessage()))
Enter fullscreen mode Exit fullscreen mode
  • errorMessage is a normal method on a class (Error) — typical OOP.
  • Left is a constructor (Either).
  • delay is a static method on a singleton object (IO).
  • pure is an extension method (for anything with an Applicative).

The first variant particularly relies on extension methods to get this consistent syntax:

def foo(err: Error) = err.errorMessage().asLeft.pure[IO]
Enter fullscreen mode Exit fullscreen mode

It’s not as complicated as it might appear.

Just one more thing.

Functions and methods

In Scala, there are Functions:

val function1: String => Int = _.length()
Enter fullscreen mode Exit fullscreen mode

And we can convert methods to functions:

class Tmp:
  def method(s: String): Int = s.length()

val function2: String => Int = Tmp().method

Tmp().method("test") // 4
function1("test")    // 4
function2("test")    // 4
Enter fullscreen mode Exit fullscreen mode

đŸ„ˆÂ  In Scala 2, it’s slightly more verbose: new Tmp().method _

Even if you miss functions, get used to declaring methods (and when needed use them as functions). The transitions are so seamless that I usually don’t think about them. And if you are new to OOP, good luck. See the official docs, write/read code, and give it some time.

Function and method signatures

Functions are expressions and their types can be (often) inferred:

val function2 = Tmp().method
Enter fullscreen mode Exit fullscreen mode
val nope = _.length()
Enter fullscreen mode Exit fullscreen mode

When you declare a non-zero-arity methods, you can’t omit the types of parameters:

def method(s: String): Int = s.length()
Enter fullscreen mode Exit fullscreen mode

While the return type is optional (it’s usually a bad practice):

def method(s: String) = s.length()
Enter fullscreen mode Exit fullscreen mode

The unit return type (doesn’t return any value):

def helloWorld(): Unit = {
  println("Hello, World!")
}
Enter fullscreen mode Exit fullscreen mode

⚠ It’s not functional but something you might encounter.

In Scala, we can use default parameter values, named arguments


def helloWorld(greet: String = "Hello", name: String = "World"): Unit = {
  println(s"$greet, $name!")
}

helloWorld(name = "User")
Enter fullscreen mode Exit fullscreen mode

and multiple paramater lists:

def helloWorld(greet: String)(name: String, alias: String): Unit = ???
Enter fullscreen mode Exit fullscreen mode

💡 The ??? is almost like undefined (only it’s not lazy and not type-inference friendly).

Names don’t matter?

Scala community has less tolerance for operators.

def foo(userId: UserId): IO[Result] = for 
  user <- fetchUser(userId)
  subscription <- findSubscription(user.subscriptionId)
yield subscription.fold(_ => defaultSubscription, withDiscount)
Enter fullscreen mode Exit fullscreen mode

There’re some in DSLs, but there no <$>, there is map; there is no >>= or <*> , but there are flatMap and mapN:

"1".some.flatMap(_.toIntOption) // Some(1)

(2.some, 1.some).mapN(_ + _)    // Some(3)
Enter fullscreen mode Exit fullscreen mode

There is traverse but no mapM, see either traverse or foreach. This depends on the library and we’ll talk about this later.

💡 Friendly advice: also get familiar with flatTap, tap, and alike.

Basic concepts and fp

Function composition

Function composition isn’t common. There are ways to do it (e.g., there is compose and andThen), but nobody does it (e.g., currying and type inference aren’t helping).

Currying

Similar here. You can curry a function but rarely want to or need to.

def helloWorld(greet: String, name: String): String =
  s"$greet, $name!"

val one = helloWorld.curried // : String => String => String
val two = one("Hello")       // : String => String
val res = two("World")       // Hello, World!
Enter fullscreen mode Exit fullscreen mode

Because, for instance, you can partially apply “normal” scala functions/methods:

val two = helloWorld("Hello", _) // : String => String
val res = two("World")           // Hello, World!
Enter fullscreen mode Exit fullscreen mode

On a relevant note, get familiar with tuples and function parameters (and how they play and don’t play together).

val cache = Map.empty[String, Int]

def foo(t: (String, Int)): Int = ???
// This works in Scala 2 and Scala 3
cache.map(foo)

def bar(s: String, i: Int): Int = ???
// This works only in Scala 3
cache.map(bar)
Enter fullscreen mode Exit fullscreen mode

đŸ„ˆÂ  In Scala 2, there are cases when you need to be explicit (see tupled) and use more explicit parenthesis.

Purity

Scala is not Haskell. For example, you can add a println ✹ anywhere ✹.

You should use a linter or an alternative way to enable recommended compiler options for fp scala. For example, sbt-tpolecat.

Laziness

If you want a lazy variable, see lazy val. If you want a lazy list, see standard library LazyList or alternatives in other libraries (for example, fs2).

However, if you want another kind of laziness: make some thunks, write recursion, or whatever, it’s not that straightforward — beware of stack (safety).

  • See cats-effect IO and ZIO (we cover IO later).
  • See cats Eval data type (or other alternatives).
  • See @tailrec.
  • See tailRecM and other ways to trampoline.

⚠ Beware of the standard library Futures.

Modules and Imports

Good news: Scala has first-class modules and you don’t need to qualify imports or prefix fields. And you can nest them!

class One:
  class Two:
    val three = 3
Enter fullscreen mode Exit fullscreen mode

Bad news: you have to get familiar with classes, objects, companion objects, and how to choose where to put your functions.

Terrible news: you still have to worry about imports because they can affect the program’s behavior (we talk about this (implicits) later).

Standard library

Scala comes with a bundle of immutable collections (List, Vector, Map, Set, etc.). In the beginning, pay attention, ensure that you are using immutable versions, and stay away from overgeneralized classes, like Seq.

Also, be open-minded — in many cases, Scala has better methods/combinators than you might expect.

Equality

In Scala 2, multiversal equality was hell for me:

4 == Some(4)
Enter fullscreen mode Exit fullscreen mode

In Scala 3, it’s not allowed:

3 == Some(3)
^^^^^^^^^^^
Values of types Int and Option[Int] cannot be compared
Enter fullscreen mode Exit fullscreen mode

But out of the box, you can still shoot yourself:

case class A(i: Int)
case class B(s: String)

A(1) == B("1") // false
Enter fullscreen mode Exit fullscreen mode

You can disable it with the strictEquality compiler flag:

import scala.language.strictEquality

case class A(i: Int) derives CanEqual
case class B(s: String) derives CanEqual

A(1) == A(2) // false

A(1) == B("1")
// ^^^^^^^^^^^
// Values of types A and B cannot be compared
Enter fullscreen mode Exit fullscreen mode

Types

Type inference

When I was thinking about this guide a couple of years ago, I thought it was going to be the beefiest chapter. Luckily, Scala 3 is pretty good at type inference.

And yeah, it’s still not Haskell and sometimes you need to help the compiler:

val id = a => a
// error:
// Missing parameter type
// I could not infer the type of the parameter a
Enter fullscreen mode Exit fullscreen mode
val id: [A] => A => A = 
  [A] => a => a

val idInt = (a: Int) => a
Enter fullscreen mode Exit fullscreen mode

It could come up during refactoring. For example, this is fine

case class Foo(s: Set[Int])

val f1 = Foo(Set.empty)
Enter fullscreen mode Exit fullscreen mode

And this doesn’t compile (empty should be helped):

case class Foo(s: Set[Int])

val s1 = Set.empty
val f1 = Foo(s1)
Enter fullscreen mode Exit fullscreen mode

But once again, don’t worry, it’s mostly good. For instance, using monad transformers used to be type-inference hell — in Scala 3, it’s fine:

def foo(userId: UserId): IO[Result] = (for 
  user <- EitherT.right(fetchUser(userId))
  subscription <- EitherT(findSubscription(user.subscriptionId))
  _ <- EitherT.right(IO.println("Log message"))
yield withDiscount(subscription)).valueOr(_ => defaultSubscription)
Enter fullscreen mode Exit fullscreen mode

đŸ„ˆÂ If you’re on Scala 2, you might need to get in the habit of annotating intermediate values. And just be gentler to the compiler!

Union types

Union types are great.

But even union types need an occasional help:

def foo(userId: UserId): EitherT[IO, MyError, String] = for 
  x <- thisThrowsA() // At least one needs to be explicitly casted
  y <- thisThrowsB().leftWiden[MyError]
yield "result"
Enter fullscreen mode Exit fullscreen mode
case class NotFound()
case class BadRequest()

type MyError = NotFound | BadRequest

def thisThrowsA(): EitherT[IO, NotFound, String] = ???
def thisThrowsB(): EitherT[IO, BadRequest, String] = ???
Enter fullscreen mode Exit fullscreen mode

Product types

Product types aren’t bad either. You use case classes:

case class User(name: String, subscriptionId: SubscriptionId)

val user = User("Kat", SubscriptionId("paypal-7"))

user.name // Kat
Enter fullscreen mode Exit fullscreen mode

Which come with a copy method to modify fields:

val resu = user.copy(subscriptionId = SubscriptionId("apple-12")) 
Enter fullscreen mode Exit fullscreen mode

And 95% of the time it’s enough. When you need to, you can use optics (for example, via monocle) or data transformations via libraries like chimney.

Sum types

Sum types are a bit more awkward.

In Scala 2, we used to model sum types with sealed trait hierarchies:

sealed trait Role
case class Customer(userId: UserId) extends Role
case class Admin(userId: UserId) extends Role
case object Anon extends Role
Enter fullscreen mode Exit fullscreen mode

We used to do the same for enums (with some boilerplate) or use the enumeratum library.

sealed trait Role
case object Customer extends Role
case object Admin extends Role
case object Anon extends Role
Enter fullscreen mode Exit fullscreen mode

đŸ€”Â enumeratum was made as an alternative to Scala-2-built-in Enumeration.

In Scala 3, we have nicer enums:

enum Role:
  case Customer, Admin, Anon
Enter fullscreen mode Exit fullscreen mode

Which are general enough to support ADTs:

enum Role:
  case Customer(userId: UserId)
  case Admin
  case Anon
Enter fullscreen mode Exit fullscreen mode

Note: Some still use enumeratum with Scala 3.

Newtypes

Newtypes are even more awkward.

In Scala 2, we used Value Classes:

class SubscriptionId(val value: String) extends AnyVal
Enter fullscreen mode Exit fullscreen mode

Scala 3 has Opaque Types:

opaque type UserId = String
Enter fullscreen mode Exit fullscreen mode

But the thing is, by themselves, both aren’t ergonomic and require boilerplate. So, you need either embrace manual wrapping, unwrapping, and other boilerplate OR use one of the many newtype libraries.

Pattern matching

There shouldn’t be anything too surprising about pattern matching (just watch out for parenthesis):

def getName(user: Option[User]): String = 
  user match {
    case Some(User(name, _)) if name.nonEmpty => name
    case _ => "anon"
  }
Enter fullscreen mode Exit fullscreen mode

However, you should know where pattern matching comes from. Scala allows pattern matching on objects with an unapply method. Case classes (like User) and enums (like Role) possess it out of the box. But if we need to, we can provide additional unapply methods or implement unapply for other classes.

class SubscriptionId(val value: String) extends AnyVal

object SubscriptionId:
  def unapply(id: SubscriptionId): Option[String] =
    id.value.split("-").lastOption
Enter fullscreen mode Exit fullscreen mode
SubscriptionId("paypal-12") match {
  case SubscriptionId(id) => id
  case _ => "oops"
}
Enter fullscreen mode Exit fullscreen mode

💡 See extractor objects.

Polymorphism

Type parameters are confined in square brackets:

def filter[A](list: List[A], p: A => Boolean): List[A] = 
  list.filter(p)
Enter fullscreen mode Exit fullscreen mode

Square brackets are also used for type applications:

val x = List.empty[Int]
Enter fullscreen mode Exit fullscreen mode

Type classes, implicits and givens

I don’t think it makes sense for me to go into too much detail here, especially, given the differences between Scala 2 and Scala 3. Just a few things you should put into your hippocampus:

In Scala 2, instance declarations are implicits:

implicit val example: Monad[Option] = new Monad[Option] {
  def pure[A](a: A): Option[A] = Some(a)

  def flatMap[A, B](ma: Option[A])(f: A => Option[B]): Option[B] = 
    ma match {
      case Some(x) => f(x)
      case None => None
    }
}
Enter fullscreen mode Exit fullscreen mode

In Scala 3, type classes are more integrated; you write given instances:

given Monad[Option] with {
  def pure[A](a: A): Option[A] = Some(a)

  def flatMap[A, B](ma: Option[A])(f: A => Option[B]): Option[B] = 
    ma match {
      case Some(x) => f(x)
      case None => None
    }
}
Enter fullscreen mode Exit fullscreen mode

In Scala 2 and Scala 3, context looks something like this:

//     ................
def foo[F[_]: Monad, A](fa: F[A]): F[(A, A)] = 
  for 
    a1 <- fa
    a2 <- fa
  yield (a1, a2)
Enter fullscreen mode Exit fullscreen mode

💡 F[_] and F[A] in Scala is as conventional as m a in Haskell.

Sometimes, in Scala 2, they look like this:

def foo[F[_], A](fa: F[A])(implicit Monad: Monad[F]): F[(A, A)] = ???
Enter fullscreen mode Exit fullscreen mode

Instances

It’s common to put instances into companion objects:

case class User(name: String, subscriptionId: SubscriptionId)

object User:
  implicit val codec: Codec[User] = deriveCodec[User]
Enter fullscreen mode Exit fullscreen mode

Another place to look for instances is objects named implicits, for example.

import my.something.implicits._
Enter fullscreen mode Exit fullscreen mode

This means that you have to remember what to import, and imports technically affect the logic of the program. In application code, it used to be somewhat common but seems to be less popular. It’s still the way to get instances for libraries that integrate with other libraries. For example, iron + circe.

Also, in Scala 3, there is a special form of import for given instances:

import my.something.given // Scala 3
Enter fullscreen mode Exit fullscreen mode

💡 Don’t forget to read more about implicits or givens on your own.

Deriving (from library user perspective)

In Scala 2, the most popular ways to get instances are automatic and semi-automatic derivations.

Automatic derivation is when you import something and “magically” get all the instances and functionality; for example, circe json decoders:

import io.circe.generic.auto._
Enter fullscreen mode Exit fullscreen mode

Semi-automatic derivation is a bit more explicit, for example:

import io.circe.generic.semiauto._

implicit val codec: Codec[User] = deriveCodec[User]
Enter fullscreen mode Exit fullscreen mode

Scala 3 has type class derivation:

case class User(name: String, subscriptionId: SubscriptionId) 
  derives ConfiguredCodec
Enter fullscreen mode Exit fullscreen mode

Note that you can still use semi-auto derivations with Scala 3 (when needed):

object User:
  given Codec[User] = deriveCodec[User]
Enter fullscreen mode Exit fullscreen mode

Consult with the docs of concrete libraries.

Meta Programming

  • There are “experimental” macros in Scala 2 and multiple features in Scala 3.
  • There is shapeless (for scrap-your-boilerplate-like generic programming) for Scala 2 and built-in “shapeless” mechanism in Scala 3

Deriving (from library author perspective)

In Scala 2, it’s common to use shapeless and magnolia for typeclass derivation.

Scala 3 has built-in low level derivation. It’s still common to use shapeless and magnolia.

Best practices

Failure handling

  • Idiomatic Scala code does not use null. Don’t worry.
  • Either is Either, Maybe is called Option.
  • You might see Try here and there.
  • See Throwable (cousin of Exception and SomeExceptions)

For everything else, see what your stack/libraries of choice have in terms of failure handling.

Styles and flavors

There existed a lot of different scalas throughout the years. In 2024, fp-leaning industry-leaning scala converges into two: typelevel and zio stacks. Roughly speaking, both come with their own standard library (prelude), IO runtime, concurrency, libraries, and best practices.

đŸ€”Â Scala’s standard library is quite functional, but not functional enough. That’s why we have these auxiliary ecosystems.

Even more roughly speaking:

  • If you’re leaning towards using mtl/transformers, see typelevel.
  • If you’re leaning towards using app monads with “baked-in” EitherT and ResourceT, see zio.

There was a moment when people used free and effect system in production Scala code, but it sucked. So, I don’t think anyone uses either in prod these days. Some library authors still use free.

đŸ€”Â There is a hype train forming around “direct-style” Scala. Actually, there are multiple trains — because the term is so ambiguous — multiple styles packaged and sold under the same name. If you’re curious, look into it yourself.

If you get an “fp” scala job (around 2024), the newer services are going to be written using typelevel or zio (and there probably going to be legacy code in other styles).

Typelevel / cats

đŸ€”Â Note that nothing stops you from using alternative libraries; especially, if they provide required instances or interoperability/conversions. For example, it seems common to use tapir instead (on top of) http4s for writing http servers in the last years. Tapir integrates with all major Scala stacks.

It’s common to organize code via “tagless final”:

trait Subscription[F[_]] {
  def fetchSubscription(subscriptionId: SubscriptionId): F[Subscription]
  def revokeSubscription(subscriptionId: SubscriptionId): F[Unit]
  def findSubscription(userId: UserId): F[Option[UserId]]
}
Enter fullscreen mode Exit fullscreen mode

It’s common to tie your app together via Resource:

def server: Resource[IO, Resource[IO, Server]] =
  for
    config <- Config.resource
    logger <- Tracing.makeLogger[IO](config.logLevel)
    client <- HttpClientBuilder.build
    redis  <- Redis.resource(config.redis)
    kafka  <- KafkaConsumer.resource(config.kafka)
    db     <- Postgres.withConnectionPool(config.db).resource
    httpApp = Business.make(config, client, redis, kafka, db)
  yield HttpServer.make(config.port, config.host, httpApp)
Enter fullscreen mode Exit fullscreen mode

It’s common to write business logic and organize control flows via fs2 streams:

fs2.Stream
  .eval(fetchSubscriptions(baseUri))
  .evalTap(_ => log.debug("Fetched subscriptions..."))
  .map(parseSubscriptions).unNone
  .filter(isValid)
  .parEvalMapUnordered(4)(withMoreData(baseUri))
  ...
Enter fullscreen mode Exit fullscreen mode

ZIO

ZIO ecosystem comes with a lot of batteries. ZIO[R, E, A] is a core data type. See usage (it’s the way to organize the code, deal with resources/scopes, and control flows).

Tooling

You can use coursier to install Scala tooling ghcup-style.

Build tools

I’ve never used anything but sbt with Scala. If it doesn’t work for you for some reason, I can’t be much of a help here.

  • See mill
  • See other java build tools (like maven) or multi-language build tools (like pants or bazel)

Miscellaneous

Libraries

Searching for functions

There’s nothing like hoogle. The dot-completion and the go-to-definition for libraries often work. But, honestly, I regularly search through the github looking for functions (and instances). I don’t know what normal people do.

Searching for libraries

  • Search in the organization/project lists. Example 1 and Example 2.
  • Search on scaladex.
  • Search on github.
  • Ask a friend.
  • Just use a java library.

Managing dependency versions

There is no stackage.

And you can always fall back to checking the maven repository to see what are the dependencies. An example.

Books and other resources

If you want to learn more, see docs.scala-lang.org, rockthejvm.com, and Foundations of Functional Programming in Scala.

To Haskell devs, I used to recommend underscore’s books: Essential Scala (to learn Scala) and Scala With Cats (to learn the FP side of Scala), but those are for Scala 2. The classic Functional Programming in Scala has a second edition updated for Scala 3 — it’s great if you want a deep dive into the FP side of Scala.

If you want to get weekly Scala content, see:

If you want to chat, see scala-lang.org/community.

OOP and variance

When it comes to the OOP side of Scala, I think the most important one to get familiar with is variance. You might think you won’t need it if you don’t use inheritance to mix lists of cats and dogs. However, you’ll still see those + and - around and encounter related compilation errors.

More and more libraries deal with this to make your life easier, so you might not even need to worry about it


💡 If you are familiar with functors, depending on how well you’re familiar, it might help or hurt you to apply the knowledge of variance here.

Few things you’ll need sooner or later

You might encounter different sub-typing-related type relationships. See (upper and lower) type bounds.

trait List[+A]:
  def prepend[B >: A](elem: B): NonEmptyList[B] = ???
Enter fullscreen mode Exit fullscreen mode

If you encounter something that looks like associated type, see abstract type members:

trait Buffer:
  type T
  val element: T
Enter fullscreen mode Exit fullscreen mode

If you encounter *, see varargs (for example, Array.apply())

val from = Array(0, 1, 2, 3) // in parameters

val newL = List(from*)        // splices in Scala 3  

val oldL = List(from: _*)     // splices in Scala 2
Enter fullscreen mode Exit fullscreen mode

End

Congrats, you’re one step closer to mastering Scala. Just a few hundred more to go.


Top comments (0)