Iso
Iso[A, B] is a bidirectional, lossless isomorphism: it converts A → B (to) and B → A (from) with no information loss.
apparatus derives Iso instances automatically for products (case classes) and coproducts (sealed traits / enums) via scala.deriving.Mirror. This lets you write machines in terms of primitive tuples and right-nested Eithers, then lift them to your domain ADTs with a single .imap call.
import apparatus.core.*
import apparatus.core.machines.*
import cats.effect.SyncIO
import cats.implicits.*Products — case class ↔ tuple
productIso derives Iso[ElemTypes, CaseClass] where ElemTypes is the flat tuple of field types.
case class Point(x: Double, y: Double)
val isoPoint = summon[Iso[(Double, Double), Point]]
// isoPoint: productIso[Point, Tuple2[Double, Double]] = apparatus.core.Iso$productIso@450865da
isoPoint.to((1.0, 2.0)) // Point(1.0, 2.0)
// res0: Point = Point(x = 1.0, y = 2.0)
isoPoint.from(Point(3.0, 4.0)) // (3.0, 4.0)
// res1: Tuple2[Double, Double] = (3.0, 4.0)The round-trips are total and lossless:
val p = Point(7.0, -1.5)
// p: Point = Point(x = 7.0, y = -1.5)
assert(isoPoint.to(isoPoint.from(p)) == p)
val t = (42.0, 0.0)
// t: Tuple2[Double, Double] = (42.0, 0.0)
assert(isoPoint.from(isoPoint.to(t)) == t)Coproducts — sealed trait ↔ right-nested Either
sumIso derives Iso[NE, SealedTrait] where NE is the right-nested Either built from the subtypes in declaration order.
sealed trait Color
case object Red extends Color // → Left(Red)
case object Green extends Color // → Right(Left(Green))
case object Blue extends Color // → Right(Right(Blue))
NE = Either[Red.type, Either[Green.type, Blue.type]]sealed trait Op
case class Add(n: Int) extends Op
case class Mul(n: Int) extends Op
case object Reset extends Op
val isoOp = summon[Iso[Either[Add, Either[Mul, Reset.type]], Op]]
// isoOp: sumIso[Op, *:[Add, *:[Mul, *:[Reset, EmptyTuple]]], Either[Add, Either[Mul, Reset]]] = apparatus.core.Iso$sumIso@3ba80498
isoOp.from(Add(1)) // Left(Add(1))
// res4: Either[Add, Either[Mul, Reset]] = Left(Add(1))
isoOp.from(Mul(3)) // Right(Left(Mul(3)))
// res5: Either[Add, Either[Mul, Reset]] = Right(Left(Mul(3)))
isoOp.from(Reset) // Right(Right(Reset))
// res6: Either[Add, Either[Mul, Reset]] = Right(Right(Reset))
isoOp.to(Right(Left(Mul(3)))) // Mul(3)
// res7: Op = Mul(3)Apparatus.imap — lifting machines to ADTs
Apparatus.imap uses Iso to adapt a machine's input and output simultaneously. It requires two Iso instances to be in scope: one for the input type and one for the output type.
Product example
case class In(x: Int, flag: Boolean)
case class Out(label: String, value: Double)
val mat = DeciderMaterializer.syncIO
// mat: DeciderMaterializer[[A >: Nothing <: Any] =>> SyncIO[A]] = apparatus.core.machines.DeciderMaterializer$$anon$2@298cfc85
val tupleFsm: Apparatus[SyncIO, (Int, Boolean), (String, Double)] =
Apparatus.closedMealy(ClosedMealy.stateless[SyncIO, (Int, Boolean), (String, Double)] {
case (n, b) => SyncIO.pure(if b then "yes" else "no", n.toDouble)
})
// tupleFsm: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], Tuple2[Int, Boolean], Tuple2[String, Double]] = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@55d4d353)
// )
// Lift to case-class I/O with imap
val adtFsm: Apparatus[SyncIO, In, Out] = tupleFsm.imap
// adtFsm: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], In, Out] = HFix2(
// Sequential(
// left = HFix2(
// Sequential(
// left = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@8786e08)
// ),
// right = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@55d4d353)
// )
// )
// ),
// right = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@676ee420)
// )
// )
// )
val out: Out = Apparatus.run(adtFsm, In(42, true), mat).unsafeRunSync()
// out: Out = Out(label = "yes", value = 42.0)Coproduct example
||| is left-associative; sumIso uses right-nesting — group explicitly with parentheses.
sealed trait Result
case class Doubled(n: Int) extends Result
case class Negated(n: Int) extends Result
case object Zero extends Result
val doubleFsm: Apparatus[SyncIO, Add, Doubled] =
Apparatus.closedMealy(ClosedMealy.stateless[SyncIO, Add, Doubled](c => SyncIO.pure(Doubled(c.n * 2))))
// doubleFsm: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], Add, Doubled] = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@4a925f88)
// )
val negateFsm: Apparatus[SyncIO, Mul, Negated] =
Apparatus.closedMealy(ClosedMealy.stateless[SyncIO, Mul, Negated](c => SyncIO.pure(Negated(-c.n))))
// negateFsm: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], Mul, Negated] = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@1217f6d1)
// )
val resetFsm2: Apparatus[SyncIO, Reset.type, Zero.type] =
Apparatus.closedMealy(ClosedMealy.stateless[SyncIO, Reset.type, Zero.type](_ => SyncIO.pure(Zero)))
// resetFsm2: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], Reset, Zero] = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@4b05eebb)
// )
// Right-group to match sumIso nesting
val eitherFsm = doubleFsm ||| (negateFsm ||| resetFsm2)
// eitherFsm: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], Either[Add, Either[Mul, Reset]], Either[Doubled, Either[Negated, Zero]]] = HFix2(
// Alternative(
// left = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@4a925f88)
// ),
// right = HFix2(
// Alternative(
// left = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@1217f6d1)
// ),
// right = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@4b05eebb)
// )
// )
// )
// )
// )
val adtFsm2: Apparatus[SyncIO, Op, Result] = eitherFsm.imap
// adtFsm2: HFix2[[F >: Nothing <: [_$2 >: Nothing <: Any, _$3 >: Nothing <: Any] =>> Any, I >: Nothing <: Any, O >: Nothing <: Any] =>> ApparatusF[F, [A >: Nothing <: Any] =>> SyncIO[A], I, O], Op, Result] = HFix2(
// Sequential(
// left = HFix2(
// Sequential(
// left = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@5235eda3)
// ),
// right = HFix2(
// Alternative(
// left = HFix2(
// ClosedMachine(
// apparatus.core.machines.ClosedMealy$$anon$3@4a925f88
// )
// ),
// right = HFix2(
// Alternative(
// left = HFix2(
// ClosedMachine(
// apparatus.core.machines.ClosedMealy$$anon$3@1217f6d1
// )
// ),
// right = HFix2(
// ClosedMachine(
// apparatus.core.machines.ClosedMealy$$anon$3@4b05eebb
// )
// )
// )
// )
// )
// )
// )
// ),
// right = HFix2(
// ClosedMachine(apparatus.core.machines.ClosedMealy$$anon$3@46a7d9d9)
// )
// )
// )
val r1: Result = Apparatus.run(adtFsm2, Add(5), mat).unsafeRunSync()
// r1: Result = Doubled(10)
val r2: Result = Apparatus.run(adtFsm2, Mul(3), mat).unsafeRunSync()
// r2: Result = Negated(-3)
val r3: Result = Apparatus.run(adtFsm2, Reset, mat).unsafeRunSync()
// r3: Result = ZeroWhy isomorphisms matter
Tuples and right-nested Eithers are the canonical structural types that Scala's Mirror exposes. They compose well generically but are verbose in domain code.
Iso bridges the two worlds:
- Write machines using structural types — easy to compose, test, and parameterise.
- Expose machines using domain ADTs — readable, type-safe API for callers.
No runtime overhead: Iso.to and Iso.from compile to direct field access or a single pattern match, equivalent to what you would write by hand.