Skip to content

Micro FP Library

Usually, in macros, we have both libraries that are only needed for compilation and libraries that are needed at runtime on the JVM class path.

If our library relied on, e.g., Cats in macros, it would not be an issue if the macros used Cats types and methods in generated code. As a matter of fact, the dependency would be obvious and required.

But if macros relied on Cats/Scalaz/ZIO/some other big ecosystem, and users did not use it in their code, that would introduce unnecessary friction. If they decided to add it but needed a different major binary-incompatible version, they would be blocked from doing so—not due to code that they control, but because it would be their almost-compile-time-only dependency that they might not be able to remove safely.

Meanwhile, some FP functionalities are very useful for writing macros, so it would be a shame to give up on them completely.

Installation

Hearth Micro FP versions
Hearth Micro FP Scala.js 1.x versions
Hearth Micro FP Scala Native 0.5 versions

sbt

JVM only:

libraryDependencies += "com.kubuszok" %% "hearth-micro-fp" % "0.3.0"

JVM/Scala.js/Scala Native via sbt-crossproject, sbt-projectmatrix or sbt 2:

libraryDependencies += "com.kubuszok" %%% "hearth-micro-fp" % "0.3.0"

Scala CLI

JVM only:

//> using dep "com.kubuszok::hearth-micro-fp:0.3.0"

JVM/Scala.js/Scala Native:

//> using dep "com.kubuszok::hearth-micro-fp::0.3.0"

Warning

Micro FP is a dependency of a core Hearth library, so if you are using it, you don't need to pull it in.

You only need to add it if you want to use type classes/macro IO without the rest of Hearth goodies.

Warning

These utilities are intended for macro-only usage. Using them in the production code is highly discouraged.

Examples in this documentation

The majority of the examples in this documentation are intended to be runnable.

That's why most file names follow the conventions from sbt + projectmatrix (or sbt 2.0).

However, all runnable examples are tested using Scala CLI and contain the directives needed to make it happy.

Library's Scope

From my experience, when writing macros you may need to:

  1. aggregate errors, so that users are informed about all the issues preventing compilation at once
  2. occasionally handle some combinator-hostile cases like this:

    You cannot always .traverse

    '{
      new TypeClass[A] {
        def implementation(arg: A): Result = ${
          // Returns F[Expr[Result]]
          // - how to extract Expr[Result] safely?
          generateImpl('{ arg })
        }
      } 
    } // Expr[TypeClass[A]]
    
  3. provide some way of debugging the code - normally, people have logs for that, but when it comes to macros, they somehow forget about providing them.

Usually the first issue is solved by Cats users with something like .parTraverse and some data type that allows error aggregation. .parTraverse implies Traverse and Parallel type classes.

Occasionally, one might prefer .traverse, which adds Applicative, and all three imply the Functor type class.

And... that's it. That's all the Cats-like type classes that the rest of the library relies on. You might add your own, but the library aims to stay micro, to not encourage using it to replace some full-blown FP library. No Monads, no effect type classes, no dependency-injection patterns - the bare minimum to let folks write slightly saner code in macros, but without jumping into enterprise, type-level FP.

The only additional type class needed handles the second case:

Generic DirectStyle type class

DirectStyle[F].scoped { runSafe =>
  '{
    new TypeClass[A] {
      def implementation(arg: A): Result = ${
        runSafe(generateImpl('{ arg })) // turns F[Expr[Result]] into Expr[Result]
      }
    } 
  }
} // : F[Expr[TypeClass[A]]]

The third issue is addressed by introducing MIO (Macro IO) and Log.

All of these are opt-in. You don't have to use extension methods if you don't want to. You don't have to use MIO and Log.

The rest of the library does not require you to write code with IO or type classes. However, it assumes in a few utilities that whatever type you use for handling errors has these type classes provided (and e.g. for Option/Either they are).

Type classes

The micro-fp library provides a minimal set of type classes for functional programming patterns commonly needed in macros. All type classes are designed to be lightweight and focused on practical use cases.

Type Classes Hierarchy

Functor
├── Applicative
│   ├── Parallel
│   │   └────────────────────────────────────┐
│   └──────────┐                             │
│              │                             │        
└── Traverse   │                             │
    └──────────┤                             │
               └── ApplicativeTraverse       │
                   (Applicative + Traverse)  │
                   └─────────────────────────┤
                                             └── ParallelTraverse
                                                 (Parallel + Traverse)

DirectStyle

Required Imports

To use the type classes and their syntax, import:

import hearth.fp.syntax.*
import hearth.fp.instances.*

Functor

Represents types that can be mapped over. The map operation transforms values inside a context without changing the context structure.

No example

Since all structures for which Functor is defined have .map already implemented as a class method, we'll skip this example.

Applicative

Extends Functor with the ability to lift values into a context (pure) and combine two contexts with a function (map2). Provides sequential, fail-fast semantics.

Lifting values with pure

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
//> using plugin org.typelevel:::kind-projector:0.13.4
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Lift a value into Option
val lifted: Option[String] = "hello".pure[Option]
pprint.pprintln(lifted)
// expected output:
// Some(value = "hello")

// Lift into Either
val liftedEither: Either[String, Int] = 42.pure[Either[String, *]]
pprint.pprintln(liftedEither)
// expected output:
// Right(value = 42)

Combining contexts with map2

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Combine two Options
val name: Option[String] = Some("Alice")
val age: Option[Int] = Some(30)
val person: Option[String] = name.map2(age) { (n, a) =>
  s"$n is $a years old"
}
pprint.pprintln(person)
// expected output:
// Some(value = "Alice is 30 years old")

// Combine with failure (fail-fast, only the first error matters)
val name2: Option[String] = Some("Bob")
val age2: Option[Int] = None
val person2: Option[String] = name2.map2(age2) { (n, a) =>
  s"$n is $a years old"
}
pprint.pprintln(person2)
// expected output:
// None

Parallel

Extends Applicative with parallel semantics. Unlike Applicative which has fail-fast behavior, Parallel can aggregate errors and run computations independently.

Parallel error aggregation

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Using Either[List[String], *] for error aggregation
val name: Either[List[String], String] = Left(List("Name is required"))
val age: Either[List[String], Int] = Left(List("Age must be positive"))

// Both errors are collected, not just the first one
val result: Either[List[String], String] = name.parMap2(age) { (n, a) =>
  s"$n is $a years old"
}
pprint.pprintln(result)
// expected output:
// Left(value = List("Name is required", "Age must be positive"))

Parallel vs Sequential behavior

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Using Either[List[String], *] for error aggregation
val name: Either[List[String], String] = Left(List("Name is required"))
val age: Either[List[String], Int] = Left(List("Age must be positive"))

// Sequential (Applicative) - fails on first error
val seqResult = name.map2(age) { (n, a) => s"$n is $a" }
pprint.pprintln(seqResult)
// expected output:
// Left(value = List("Name is required")

// Parallel - collects all errors
val parResult = name.parMap2(age) { (n, a) => s"$n is $a" }
pprint.pprintln(parResult)
// expected output:
// Left(value = List("Name is required", "Age must be positive"))

Traverse

Represents types that can be traversed with an effect. Allows transforming each element with an effectful function and collecting the results.

Traversing collections

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Traverse List with Option
val numbers: List[Int] = List(1, 2, 3, 4)
val validateEven: Int => Option[Int] = n => 
  if (n % 2 == 0) Some(n) else None

// It will fail, because 1 and 3 are not even
val result: Option[List[Int]] = numbers.traverse(validateEven)
pprint.pprintln(result)
// expected output:
// None

// Traverse with success
val evenNumbers: List[Int] = List(2, 4, 6, 8)
val success: Option[List[Int]] = evenNumbers.traverse(validateEven)
pprint.pprintln(success)
// expected output:
// Some(value = List(2, 4, 6, 8))

Sequencing effects

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Convert List[Option[A]] to Option[List[A]]
val maybeNumbers: List[Option[Int]] = List(Some(1), Some(2), None, Some(4))
// There is a None in the middle, so this should be None
val sequenced: Option[List[Int]] = maybeNumbers.sequence
pprint.pprintln(sequenced)
// expected output:
// None

// All Some values
val allSome: List[Option[Int]] = List(Some(1), Some(2), Some(3))
val sequenced2: Option[List[Int]] = allSome.sequence
pprint.pprintln(sequenced2)
// expected output:
// Some(value = List(1, 2, 3))

Parallel traversal

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Parallel traversal with error aggregation
val validateName: String => Either[List[String], String] = name =>
  if (name.isEmpty) Left(List("Name cannot be empty"))
  else Right(name)

val validateAge: Int => Either[List[String], Int] = age =>
  if (age < 0) Left(List("Age must be positive"))
  else Right(age)

val names: List[String] = List("", "Alice", "")
val ages: List[Int] = List(-1, 30, -5)

val nameResults: Either[List[String], List[String]] = 
  names.parTraverse(validateName)
val ageResults: Either[List[String], List[Int]] = 
  ages.parTraverse(validateAge)

// Combine with parallel semantics
val combined: Either[List[String], List[String]] = 
  nameResults.parMap2(ageResults) { (ns, as) =>
    ns.zip(as).map { case (n, a) => s"$n is $a" }
  }
pprint.pprintln(combined)
// expected output:
// Left(
//   value = List(
//     "Name cannot be empty",
//     "Name cannot be empty",
//     "Age must be positive",
//     "Age must be positive"
//   )
// )

DirectStyle

Provides a way to write effectful code in a direct style, similar to using for comprehensions but with more flexibility. Useful for complex nested computations where normal combinators are awkward.

Direct style with Option

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.DirectStyle
import hearth.fp.syntax.*
import hearth.fp.instances.*

val result: Option[String] = DirectStyle[Option].scoped { runSafe =>
  val name: String = runSafe(Some("Alice"))
  val age: Int = runSafe(Some(30))
  val city: String = runSafe(Some("New York"))

  s"$name is $age years old and lives in $city"
}
pprint.pprintln(result)
// expected output:
// Some(value = "Alice is 30 years old and lives in New York")

Direct style with error handling

//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
//> using plugin org.typelevel:::kind-projector:0.13.4
import hearth.fp.DirectStyle
import hearth.fp.syntax.*
import hearth.fp.instances.*

val result: Either[List[String], String] =
  DirectStyle[Either[List[String], *]].scoped {
    runSafe =>
    val name: String = runSafe(Right("Bob"))
    val age: Int =
      runSafe(Left(List("Age is required")))
    val city: String = runSafe(Right("London"))

    // This line is never reached due to the error above
    s"$name is $age years old and lives in $city"
  }
pprint.pprintln(result)
// expected output:
// Left(value = List("Age is required"))

Complex macro generation

// file: src/main/scala/TypeClass.scala - part of MIO Direct Style example
//> using scala 3.3.7
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
import hearth.fp.DirectStyle
import hearth.fp.effect.MIO
import hearth.fp.instances.*
import hearth.fp.syntax.*

import scala.quoted.*

// Our type class
trait TypeClass[A] {

  def method(arg: A): String
  def anotherMethod(arg: A): Int
}

object TypeClass {

  // How its methods could be generated
  def generateMethodImpl[A: Type](expr: Expr[A])(using Quotes): MIO[Expr[String]] = MIO {
    '{ ${ expr }.toString }
  }
  def generateAnotherMethodImpl[A: Type](
      expr: Expr[A]
  )(using Quotes): MIO[Expr[Int]] = MIO {
    '{ ${ expr }.toString.size }
  }

  // Example of generating complex macro code
  def generateTypeClassImpl[A: Type](
      using q: Quotes
  ): Expr[TypeClass[A]] =
    DirectStyle[MIO].scoped { runSafe =>
      '{
        new TypeClass[A] {
          def method(arg: A): String = ${
            // Extract Expr[String] from MIO
            runSafe(generateMethodImpl('{ arg }))
          }

          def anotherMethod(arg: A): Int = ${
            // Extract Expr[Int] from MIO
            runSafe(
              generateAnotherMethodImpl('{ arg })
            )
          }
        }
      }
    }.unsafe.runSync._2.match {
      case Left(errors) =>
        q.reflect.report.errorAndAbort(
          errors.map(_.getMessage).mkString(", "),
          q.reflect.Position.ofMacroExpansion
        )
      case Right(expr) => expr
    }

  inline def generateTypeClass[A]: TypeClass[A] = ${ generateTypeClassImpl[A] }
}
// file: src/test/scala/TypeClass.scala - part of MIO Direct Style example
//> using test.dep org.scalameta::munit::1.2.4

final class TypeClassSpec extends munit.FunSuite {

  test("TypeClass.generateTypeClass works") {
    val tc = TypeClass.generateTypeClass[Long]

    assertEquals(tc.method(1024L), "1024")
    assertEquals(tc.anotherMethod(1024L), 4)
  }
}

Combined Type Classes

The library provides combined type classes to avoid implicit resolution conflicts:

ApplicativeTraverse

Combines Applicative[F] and Traverse[F]. Useful when you need both capabilities.

ParallelTraverse

Combines Parallel[F] and Traverse[F]. Provides both parallel semantics and traversal capabilities.

Creating Type Class Instances

To implement type classes for your own types, follow these patterns:

Implementing Functor

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.*

// For a custom container type
case class Box[A](value: A)

implicit val FunctorForBox: Functor[Box] = new Functor[Box] {
  def map[A, B](fa: Box[A])(f: A => B): Box[B] = 
    Box(f(fa.value))
}

import hearth.fp.syntax.*

// Usage
val box: Box[Int] = Box(42)
val doubled: Box[Int] = box.map(_ * 2)
pprint.pprintln(doubled)
// expected output:
// Box(value = 84)

Implementing Applicative

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.*

// For a custom container type
case class Box[A](value: A)

implicit val ApplicativeForBox: Applicative[Box] = new Applicative[Box] {
  def pure[A](a: A): Box[A] = Box(a)

  def map2[A, B, C](fa: Box[A], fb: => Box[B])(f: (A, B) => C): Box[C] = 
    Box(f(fa.value, fb.value))
}

import hearth.fp.syntax.*

// Usage
val box1: Box[Int] = Box(10)
val box2: Box[Int] = Box(20)
val sum: Box[Int] = box1.map2(box2)(_ + _)
pprint.pprintln(sum)
// expected output:
// Box(value = 30)

Implementing Traverse

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.*

// For a custom tree structure
sealed trait Tree[A]
case class Leaf[A](value: A) extends Tree[A]
case class Node[A](left: Tree[A], right: Tree[A]) extends Tree[A]

implicit val TraverseForTree: Traverse[Tree] = new Traverse[Tree] {
  def traverse[G[_]: Applicative, A, B](fa: Tree[A])(f: A => G[B]): G[Tree[B]] = 
    fa match {
      case Leaf(a) => Applicative[G].map(f(a))(Leaf(_))
      case Node(left, right) =>
        Applicative[G].map2(traverse(left)(f), traverse(right)(f))(Node(_, _))
        // with syntax: left.traverse(f).map2(right.traverse(f))(Node(_, _))
    }

  def parTraverse[G[_]: Parallel, A, B](fa: Tree[A])(f: A => G[B]): G[Tree[B]] = 
    fa match {
      case Leaf(a) => Applicative[G].map(f(a))(Leaf(_))
      case Node(left, right) => 
        Parallel[G].parMap2(parTraverse(left)(f), parTraverse(right)(f))(Node(_, _))
        // with syntax:  left.parTraverse(f).parMap2(right.parTraverse(f))(Node(_, _))
    }
}

import hearth.fp.instances.*
import hearth.fp.syntax.*

val tree: Tree[Int] = Node(Node(Leaf(1), Leaf(2)), Leaf(3))
val t = tree.traverse { i =>
  if (i % 2 == 0) Right(i / 2)
  else Left(List(s"$i is not even"))
}
pprint.pprintln(t)
// expected output:
// Left(value = List("1 is not even"))
val p = tree.parTraverse { i =>
  if (i % 2 == 0) Right(i / 2)
  else Left(List(s"$i is not even"))
}
pprint.pprintln(p)
// expected output:
// Left(value = List("1 is not even", "3 is not even"))

Quick Reference

Type Class Purpose Key Methods Import
Functor[F] Map over values map hearth.fp.syntax.*
Applicative[F] Lift and combine pure, map2 hearth.fp.syntax.*
Parallel[F] Parallel combination parMap2 hearth.fp.syntax.*
Traverse[F] Traverse with effects traverse, sequence hearth.fp.syntax.*
DirectStyle[F] Direct style programming scoped hearth.fp.syntax.*

Built-in Instances

The library provides instances for common Scala types:

  • Id: Identity type (no wrapper)
  • Option: Optional values
  • Either[E, *]: Error handling (single error)
  • Either[List[E], *]: Error aggregation
  • Either[Vector[E], *]: Error aggregation
  • Try: Exception handling
  • List: Sequential collections
  • Vector: Indexed collections
  • NonEmptyList: Non-empty lists
  • NonEmptyVector: Non-empty vectors

Data

The library provides a few essential data types that are commonly needed in functional programming.

NonEmptyList

A non-empty list that guarantees at least one element. Useful for representing collections that cannot be empty, such as error messages or required parameters.

Creating NonEmptyList

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*

// Create with constructor
val nel1: NonEmptyList[Int] = NonEmptyList(1, List(2, 3, 4))
pprint.pprintln(nel1)
// expected output:
// NonEmptyList(head = 1, tail = List(2, 3, 4))

// Create with apply method
val nel2: NonEmptyList[String] = NonEmptyList("hello", "world", "!")
pprint.pprintln(nel2)
// expected output:
// NonEmptyList(head = "hello", tail = List("world", "!"))

// Create single element
val single: NonEmptyList[Int] = NonEmptyList.one(42)
pprint.pprintln(single)
// expected output:
// NonEmptyList(head = 42, tail = List())

// Convert from List (returns Option)
val fromList: Option[NonEmptyList[Int]] = NonEmptyList.fromList(List(1, 2, 3))
pprint.pprintln(fromList)
val fromEmpty: Option[NonEmptyList[Int]] = NonEmptyList.fromList(List.empty)
pprint.pprintln(fromEmpty)
// expected output:
// Some(value = NonEmptyList(head = 1, tail = List(2, 3)))
// None

Operations on NonEmptyList

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import hearth.fp.syntax.*
import hearth.fp.instances.*

val nel: NonEmptyList[Int] = NonEmptyList(1, List(2, 3))

// Prepend element
val prepended: NonEmptyList[Int] = 0 +: nel
pprint.pprintln(prepended)
// expected output:
// NonEmptyList(head = 0, tail = List(1, 2, 3))

// Append element
val appended: NonEmptyList[Int] = nel :+ 4
pprint.pprintln(appended)
// expected output:
// NonEmptyList(head = 1, tail = List(2, 3, 4))

// Concatenate
val other: NonEmptyList[Int] = NonEmptyList(5, List(6))
val combined: NonEmptyList[Int] = nel ++ other
pprint.pprintln(combined)
// expected output:
// NonEmptyList(head = 1, tail = List(2, 3, 5, 6))

// Map over elements
val doubled: NonEmptyList[Int] = nel.map(_ * 2)
pprint.pprintln(doubled)
// expected output:
// NonEmptyList(head = 2, tail = List(4, 6))

// Convert to other collections
val asList: List[Int] = nel.toList
pprint.pprintln(asList)
val asVector: Vector[Int] = nel.toVector
pprint.pprintln(asVector)
val asNEV: NonEmptyVector[Int] = nel.toNonEmptyVector
pprint.pprintln(asNEV)
// expected output:
// List(1, 2, 3)
// Vector(1, 2, 3)
// NonEmptyVector(head = 1, tail = Vector(2, 3))

Error aggregation with NonEmptyList

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Validate multiple fields and collect all errors
def validateName(name: String): Either[NonEmptyList[String], String] =
  if (name.isEmpty) Left(NonEmptyList.one("Name cannot be empty"))
  else if (name.length < 2) Left(NonEmptyList.one("Name must be at least 2 characters"))
  else Right(name)

def validateAge(age: Int): Either[NonEmptyList[String], Int] =
  if (age < 0) Left(NonEmptyList.one("Age must be positive"))
  else if (age > 150) Left(NonEmptyList.one("Age must be reasonable"))
  else Right(age)

// Parallel validation aggregates all errors
val name: Either[NonEmptyList[String], String] =
  Left(NonEmptyList("Name cannot be empty", "Name too short"))
val age: Either[NonEmptyList[String], Int] = Left(NonEmptyList.one("Age must be positive"))

val result: Either[NonEmptyList[String], String] = name.parMap2(age) { (n, a) =>
  s"$n is $a years old"
}
pprint.pprintln(result)
// expected output:
// Left(
//   value = NonEmptyList(
//     head = "Name cannot be empty",
//     tail = List("Name too short", "Age must be positive")
//   )
// )

NonEmptyVector

A non-empty vector that provides indexed access and better performance for larger collections compared to NonEmptyList.

Creating NonEmptyVector

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*

// Create with constructor
val nev1: NonEmptyVector[Int] = NonEmptyVector(1, Vector(2, 3, 4))
pprint.pprintln(nev1)
// expected output:
// NonEmptyVector(head = 1, tail = Vector(2, 3, 4))

// Create with apply method
val nev2: NonEmptyVector[String] = NonEmptyVector("hello", "world", "!")
pprint.pprintln(nev2)
// expected output:
// NonEmptyVector(head = "hello", tail = Vector("world", "!"))

// Create single element
val single: NonEmptyVector[Int] = NonEmptyVector.one(42)
pprint.pprintln(single)
// expected output:
// NonEmptyVector(head = 42, tail = Vector())

// Convert from Vector (returns Option)
val fromVector: Option[NonEmptyVector[Int]] = NonEmptyVector.fromVector(Vector(1, 2, 3))
pprint.pprintln(fromVector)
val fromEmpty: Option[NonEmptyVector[Int]] = NonEmptyVector.fromVector(Vector.empty)
pprint.pprintln(fromEmpty)
// expected output:
// Some(value = NonEmptyVector(head = 1, tail = Vector(2, 3)))
// None

Operations on NonEmptyVector

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import hearth.fp.syntax.*
import hearth.fp.instances.*

val nev: NonEmptyVector[Int] = NonEmptyVector(1, Vector(2, 3))

// Prepend element
val prepended: NonEmptyVector[Int] = 0 +: nev
pprint.pprintln(prepended)
// expected output:
// NonEmptyVector(head = 0, tail = Vector(1, 2, 3))

// Append element
val appended: NonEmptyVector[Int] = nev :+ 4
pprint.pprintln(appended)
// expected output:
// NonEmptyVector(head = 1, tail = Vector(2, 3, 4))

// Concatenate
val other: NonEmptyVector[Int] = NonEmptyVector(5, Vector(6))
val combined: NonEmptyVector[Int] = nev ++ other
pprint.pprintln(combined)
// expected output:
// NonEmptyVector(head = 1, tail = Vector(2, 3, 5, 6))

// Map over elements
val doubled: NonEmptyVector[Int] = nev.map(_ * 2)
pprint.pprintln(doubled)
// expected output:
// NonEmptyVector(head = 2, tail = Vector(4, 6))

// Convert to other collections
val asList: List[Int] = nev.toList
pprint.pprintln(asList)
val asVector: Vector[Int] = nev.toVector
pprint.pprintln(asVector)
val asNEL: NonEmptyList[Int] = nev.toNonEmptyList
pprint.pprintln(asNEL)
// expected output:
// List(1, 2, 3)
// Vector(1, 2, 3)
// NonEmptyList(head = 1, tail = List(2, 3))

Error aggregation with NonEmptyVector

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Validate multiple fields and collect all errors
def validateEmail(email: String): Either[NonEmptyVector[String], String] =
  if (email.isEmpty) Left(NonEmptyVector.one("Email cannot be empty"))
  else if (!email.contains("@")) Left(NonEmptyVector.one("Email must contain @"))
  else Right(email)

def validatePhone(phone: String): Either[NonEmptyVector[String], String] =
  if (phone.isEmpty) Left(NonEmptyVector.one("Phone cannot be empty"))
  else if (phone.length < 10) Left(NonEmptyVector.one("Phone must be at least 10 digits"))
  else Right(phone)

// Parallel validation aggregates all errors
val email: Either[NonEmptyVector[String], String] =
  Left(NonEmptyVector(
    "Email cannot be empty", "Email must contain @"
  ))
val phone: Either[NonEmptyVector[String], String] =
  Left(NonEmptyVector.one("Phone cannot be empty"))

val result: Either[NonEmptyVector[String], String] = email.parMap2(phone) { (e, p) =>
  s"Contact: $e, $p"
}
pprint.pprintln(result)
// expected output:
// Left(
//   value = NonEmptyVector(
//     head = "Email cannot be empty",
//     tail = Vector("Email must contain @", "Phone cannot be empty")
//   )
// )

NonEmptyMap

A non-empty ListMap that guarantees at least one key-value pair and preserves the order of insertion.

Creating NonEmptyMap

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import scala.collection.immutable.ListMap

// Create with constructor
val nem1: NonEmptyMap[String, Int] = NonEmptyMap("a" -> 1, ListMap("b" -> 2, "c" -> 3))
pprint.pprintln(nem1)
// expected output:
// NonEmptyMap(head = ("a", 1), tail = ListMap("b" -> 2, "c" -> 3))

// Create with apply method
val nem2: NonEmptyMap[String, String] =
  NonEmptyMap("name" -> "Alice", "age" -> "30", "city" -> "New York")
pprint.pprintln(nem2)
// expected output:
// NonEmptyMap(head = ("name", "Alice"), tail = ListMap("age" -> "30", "city" -> "New York"))

// Create single element
val single: NonEmptyMap[String, Int] = NonEmptyMap.one("key" -> 42)
pprint.pprintln(single)
// expected output:
// NonEmptyMap(head = ("key", 42), tail = ListMap())

// Convert from ListMap (returns Option)
val fromListMap: Option[NonEmptyMap[String, Int]] =
  NonEmptyMap.fromListMap(ListMap("a" -> 1, "b" -> 2, "c" -> 3))
pprint.pprintln(fromListMap)
val fromEmpty: Option[NonEmptyMap[String, Int]] = NonEmptyMap.fromListMap(ListMap.empty)
pprint.pprintln(fromEmpty)
// expected output:
// Some(value = NonEmptyMap(head = ("a", 1), tail = ListMap("b" -> 2, "c" -> 3)))
// None

Operations on NonEmptyMap

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import hearth.fp.syntax.*
import hearth.fp.instances.*
import scala.collection.immutable.ListMap

val nem: NonEmptyMap[String, Int] = NonEmptyMap(("a", 1), ListMap("b" -> 2, "c" -> 3))

// Prepend element
val prepended: NonEmptyMap[String, Int] = ("x", 0) +: nem
pprint.pprintln(prepended)
// expected output:
// NonEmptyMap(head = ("x", 0), tail = ListMap("a" -> 1, "b" -> 2, "c" -> 3))

// Append element
val appended: NonEmptyMap[String, Int] = nem :+ ("d", 4)
pprint.pprintln(appended)
// expected output:
// NonEmptyMap(head = ("a", 1), tail = ListMap("b" -> 2, "c" -> 3, "d" -> 4))

// Map over key-value pairs
val mapped: NonEmptyMap[String, String] =
  nem.map { case (k, v) => (k.toUpperCase, v.toString) }
pprint.pprintln(mapped)
// expected output:
// NonEmptyMap(head = ("A", "1"), tail = ListMap("B" -> "2", "C" -> "3"))

// FlatMap over key-value pairs
val flatMapped: NonEmptyMap[String, Int] = nem.flatMap { case (k, v) =>
  NonEmptyMap.one((k + "_copy", v * 2))
}
pprint.pprintln(flatMapped)
// expected output:
// NonEmptyMap(head = ("a_copy", 2), tail = ListMap("b_copy" -> 4, "c_copy" -> 6))

// Convert to other collections
val asListMap: ListMap[String, Int] = nem.toListMap
pprint.pprintln(asListMap)
// expected output:
// ListMap("a" -> 1, "b" -> 2, "c" -> 3)
val asList: List[(String, Int)] = nem.toList
pprint.pprintln(asList)
// expected output:
// List(("a", 1), ("b", 2), ("c", 3))
val asVector: Vector[(String, Int)] = nem.toVector
pprint.pprintln(asVector)
// expected output:
// Vector(("a", 1), ("b", 2), ("c", 3))
val asNEL: NonEmptyList[(String, Int)] = nem.toNonEmptyList
pprint.pprintln(asNEL)
// expected output:
// NonEmptyList(head = ("a", 1), tail = List(("b", 2), ("c", 3)))
val asNEV: NonEmptyVector[(String, Int)] = nem.toNonEmptyVector
pprint.pprintln(asNEV)
// expected output:
// NonEmptyVector(head = ("a", 1), tail = Vector(("b", 2), ("c", 3)))

Macro IO (MIO)

MIO (Macro IO) is a specialized effect type designed for safe data transformations in macros. It provides stack-safety, structured logging, error aggregation, and referential transparency without external dependencies.

Key Features

  • Stack-safe: Handles deep-nested computations without stack overflow
  • Structured logging: Build logs without macro reporter limitations
  • Error aggregation: Collect multiple errors in NonEmptyVector[Throwable]
  • Exception safety: Catches non-fatal errors automatically
  • Referential transparency: Values can be safely reused

Basic Usage

Simple MIO operations

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.effect.*

// Pure value
val pureValue: MIO[Int] = MIO.pure(42)

// Suspend computation
val suspended: MIO[String] = MIO.suspend(Right("hello"))

// Map over result
val mapped: MIO[Int] = pureValue.map(_ * 2)

// Combine with map2
val combined: MIO[String] = mapped.map2(suspended) { (i, s) =>
  s"$s: $i"
}

val (state, result) = combined.unsafe.runSync
pprint.pprintln(result)
// expected output:
// Right(value = "hello: 84")

Error handling

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.data.*
import hearth.fp.effect.*

// Suspend with error
val error: MIO[Int] = MIO.suspend(Left(
  NonEmptyVector.one(new RuntimeException("Something went wrong"))
))

// Error aggregation
val error1: MIO[Int] = MIO.suspend(Left(
  NonEmptyVector.one(new RuntimeException("Error 1"))
))
val error2: MIO[Int] = MIO.suspend(Left(
  NonEmptyVector.one(new RuntimeException("Error 2"))
))

val aggregated: MIO[Int] = error1.parMap2(error2)(_ + _)
// Both errors are collected in the result

val (state, result) = aggregated.unsafe.runSync
pprint.pprintln(result)
// expected output:
// Left(
//   value = NonEmptyVector(
//     head = java.lang.RuntimeException: Error 1,
//     tail = Vector(java.lang.RuntimeException: Error 2)
//   )
// )

Logging

Structured logging

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using options -Xsource:3
import hearth.fp.effect.*

// Log messages
val logged: MIO[Unit] = for {
  _ <- Log.info("Starting computation")
  _ <- Log.warn("This is a warning")
  _ <- Log.error("This is an error")
} yield ()

// Named scopes for better organization
val scoped: MIO[Int] = Log.namedScope("validation") {
  for {
    _ <- Log.info("Validating input")
    result <- MIO.pure(42)
    _ <- Log.info("Validation complete")
  } yield result
}

val (state, result) = (logged >> scoped).unsafe.runSync
println(state.logs.render.fromInfo("Logs example"))
// expected output:
// Logs example:
// ├ [Info]  Starting computation
// ├ [Warn]  This is a warning
// ├ [Error] This is an error
// └ validation:
//   ├ [Info]  Validating input
//   └ [Info]  Validation complete

Within MacroCommons there is also an integration provided, that would extract successful value, log and fail if necessary:

Convert errors to error message, log using info

// See our Show derivation demo in
// https://github.com/kubuszok/hearth/blob/
//   572a574a5a2b84e8196127cc20193a0fe3cc3807/hearth-tests/src/main/scala/
//   hearth/demo/ShowMacrosImpl.scala
def deriveOrFail[A: Type](value: Expr[A], name: String): Expr[String] = Log
  .namedScope(s"Derivation for $name") {
    attemptAllRules[A](value)
  }
  .runToExprOrFail(
    name,
    // Renders all logs if `shouldWeLogDerivation` and none otherwise
    infoRendering = if (shouldWeLogDerivation) RenderFrom(Log.Level.Info) else DontRender
  ) { (errorLogs, errors) =>
    // errorLogs: String - pretty-printed log
    // errors: NonEmptyVector[Throwable] - errors that
    // happened during the derivation
    // (if it succeeded, this wouldn't be called)

    val errorsStr = errors.toVector
      .map {
        case DerivationError.UnsupportedType(typeName) =>
          s"Derivation of $typeName is not supported"
        case DerivationError.UnsupportedMethod(typeName, method) =>
          s"Derivation of $typeName.$method is not supported"
        case DerivationError.AssertionFailed(message) => s"Assertion failed: $message"
        case e => s"Unexpected error: ${e.getMessage}:\n${e.getStackTrace.mkString("\n")}"
      }
      .mkString("\n")

    if (errorLogs.nonEmpty) {
      s"""Failed to derive $name:
        |$errorsStr
        |Error logs:
        |$errorLogs
        |""".stripMargin
    } else {
      s"""Failed to derive $name:
        |$errorsStr
        |""".stripMargin
    }
  }

Benchmarking Scopes and Flame Graphs

When developing macros, it can be useful to measure how long each named scope takes during expansion. Hearth provides built-in support for benchmarking scopes and generating flame graphs.

Enabling Scope Benchmarking

Set MIO.benchmarkScopes = true to capture nanosecond timestamps for each Log.namedScope. When using runToExprOrFail, this is automatically controlled via the -Xmacro-settings:hearth.mioBenchmarkScopes=true compiler option.

When enabled, the rendered log output will include duration annotations on each scope:

MyMacro:
├ validation (12345ns):
│ ├ [Info]  Validating input
│ └ [Info]  Validation complete
└ code generation (67890ns):
  └ [Info]  Generating code

Generating Flame Graphs

Hearth can generate speedscope-compatible flame graphs from benchmarked scope data. This is useful for visualizing where time is spent during macro expansion.

Automatic generation via runToExprOrFail:

Add the following compiler options to automatically generate flame graphs during compilation:

-Xmacro-settings:hearth.mioBenchmarkScopes=true
-Xmacro-settings:hearth.mioBenchmarkFlameGraphDir=/path/to/output

This will write .speedscope.json files to the specified directory, named after the source file position and macro name. Open these files at speedscope.app to visualize the flame graph.

Manual generation:

import hearth.fp.effect.*

MIO.benchmarkScopes = true
val macroStart = Log.Timestamp.now

val mio = Log.namedScope("outer") {
  Log.namedScope("inner") {
    MIO.pure(42)
  }
}

val (state, result) = mio.unsafe.runSync
state.logs.render.speedscopeFlameGraph("my-macro", macroStart).foreach { json =>
  // Write json to a file, then open at https://www.speedscope.app/
  println(json)
}

The generated JSON uses the speedscope evented format with nanosecond precision, recording open/close events for each named scope relative to the macro expansion start time.

Running MIO

Running MIO computations

If you don't want or cannot run mio.runToExprOrFail(...)(...), use mio.unsafe.runSync and then use the result and/or state information (logs, final values of MLocal).

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using options -Xsource:3
import hearth.fp.effect.*

val computation: MIO[String] = for {
  _ <- Log.info("Starting")
  value <- MIO.pure("hello")
  _ <- Log.info("Computation complete")
} yield value

// Run and get result with logs
val (state, result) = computation.unsafe.runSync

// Render logs
val logOutput: String = state.logs.render.fromInfo("MyApp")
println(logOutput)
// expected output:
// MyApp:
// ├ [Info]  Starting
// └ [Info]  Computation complete

Error-Handling Patterns

Safe computation with error recovery

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.effect.*
import hearth.fp.syntax.*
import hearth.fp.instances.*

def safeDivide(a: Int, b: Int): MIO[Int] = MIO(a / b)

// Multiple computations with error aggregation
val computations: MIO[List[Int]] = List(
  safeDivide(10, 2),
  safeDivide(10, 0),  // This will fail
  safeDivide(10, 5)
).parSequence

val (_, result) = computations.unsafe.runSync
pprint.pprintln(result)
// expected output:
// Left(value = NonEmptyVector(head = java.lang.ArithmeticException: / by zero, tail = Vector()))

Shared Parallel State (MLocal.unsafeSharedParallel)

The standard MLocal(initial)(fork)(join) gives each parallel branch an independent copy of the state (via fork) and recombines them afterwards (via join). This is correct for truly independent state like error accumulators.

However, for inherently shared/global state — like caches or deduplication maps — fork/join semantics cause problems: each branch independently builds the same entry, then join must reconcile duplicates.

MLocal.unsafeSharedParallel(initial) creates a shared-state local where parallel branches see each other's modifications sequentially: branch B sees branch A's writes, branch C sees both, etc.

No fork or join function is needed — branch B always sees branch A's latest state, and after joining, the last branch's state is used directly.

import hearth.fp.effect.*

// Shared cache — parallel branches accumulate into the same map
val cache = MLocal.unsafeSharedParallel(Map.empty[String, Int])

val branchA = cache.get.flatMap(m => cache.set(m.updated("a", 1)))
val branchB = cache.get.flatMap(m => cache.set(m.updated("b", 2)))

// B sees A's writes: final map has both entries
val program = branchA.parMap2(branchB)((_, _) => ()) >> cache.get
// result: Right(Map("a" -> 1, "b" -> 2))

When to use: caches, deduplication, counters that must be globally consistent.

When NOT to use: anything where branches need independent copies — use the standard MLocal(init)(fork)(join) for that.

Why "unsafe": breaks branch independence — the second branch depends on the first branch's execution order. Not suitable for state where fork isolation matters.

Integration with Type Classes

MIO implements all the type classes discussed earlier, making it easy to integrate with existing functional programming patterns:

MIO with type classes

//> using scala 2.13.16
//> using dep com.kubuszok::hearth-micro-fp:0.3.0
//> using dep com.lihaoyi::pprint::0.9.3
//> using options -Xsource:3
import hearth.fp.DirectStyle
import hearth.fp.effect.*
import hearth.fp.syntax.*
import hearth.fp.instances.*

// Usage with traverse
val items: List[Int] = List(1, 2, 3, 4)
val processed: MIO[List[String]] = items.traverse { item =>
  for {
    _ <- Log.info(s"Processing item $item")
    result <- MIO.pure(s"Processed: $item")
  } yield result
}

// Usage with direct style
val directResult: MIO[String] = DirectStyle[MIO].scoped { runSafe =>
  val a: Int = runSafe(MIO.pure(10))
  val b: Int = runSafe(MIO.pure(20))
  s"Sum: ${a + b}"
}

val (state, result) = (processed >> directResult).unsafe.runSync
pprint.pprintln(result)
pprint.pprintln(state.logs.render.fromInfo("Logs"))
// expected output:
// Right(value = "Sum: 30")
// """Logs:
// ├ [Info]  Processing item 1
// ├ [Info]  Processing item 2
// ├ [Info]  Processing item 3
// └ [Info]  Processing item 4
// """

Integration with MacroCommons

When using MacroCommons you don't need to use mio.unsafe.runSync.

There is a dedicated extension method for MIO[Expr[A]] that:

  • will run MIO computation
  • extract Expr[A] if it's a successful computation
  • logging if you tell it to and there are logs
  • on failure provide you with a rendered log and a non-empty collection of errors, so that you would have the whole information to render complete error message
  • handling reporting error as well

From MIO[Expr[A]] to done macro expansion

mioResult.runToExprOrFail(
  nameOfTheMacroForReport: String, // required
  infoRendering = DontRender, // or RenderFrom(logLevel) or RenderOnly(logLevel) - optional
  warnRendering = RenderFrom(Log.Level.Warn), // optional
  errorRendering = RenderFrom(Log.Level.Error), // optional
  failOnErrorLog = false // optional
) { (errorLogs: String, errors: NonEmptyVector[Throwable]) =>
  "Your error message"
}

This utility also handles Ctrl+C signal - if you manage to write such a tail recursive computation, that the program would loop and only Ctrl+C would be a reasonable course of action, MIO would interrupt ongoing computations and fail. Without it you only get an exception, with it, you will also get error message telling you where the MIO program was when the error happened, helping you find a place where it looped.

It does that with the help of Environment.handleMioTerminationException utility.

There are also utilities to handle some utilities with MIO:

  • Environment.loadMacroExtensions[MacroExtensionType]() can be made lazy, and log the results via Environment.loadMacroExtensions[MacroExtensionType]().toMIO(shouldAllowFailures) - you can use Boolean parameter shouldAllowFailures to decide if in a situation when Hearth successfully created a list of extensions, but failed to load some of them, we should treat it as a failure of good enough success (false by default)
  • instead of passing around a lot of copies of an immutable ValDefsCache, we can create the cache as MLocal via ValDefsCache.mlocal, and then use extension methods on it to "mutate" the MLocal value in an MIO program