Skip to content

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.

scala
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.

scala
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:

scala
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]]
scala
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 ​

scala
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.

scala
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 = Zero

Why 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.