| name | kindlings-factory-instance |
| description | Factory instance pattern for generating final type class instances. Lambda factories for monomorphic (kind *), erasure-based factories with Witness types for polymorphic (kind * -> *). Benchmark data showing lambda vs anonymous class performance.
|
| paths | ["**/runtime/*Factories*.scala","**/runtime/*Factory*.scala"] |
| user-invocable | false |
Skill: Factory Instance Pattern
Use this skill when generating the final type class instance in macro code. Instead of emitting
new TypeClass[A] { ... } anonymous classes, use factory methods that accept lambdas.
Reference benchmark: docs/research/anon-vs-lambda/analysis.md
Why factory methods instead of anonymous classes
When a macro generates new TypeClass[A] { def method(x: A) = ... }, the Scala compiler creates
a separate .class file for every expansion. With N derived types, this produces N class files
that the JVM must load, verify, and intern.
The factory pattern (TypeClassFactory.instance[A](lambda)) avoids this:
- The factory's inner class is defined once (1
.class file, shared)
- Per-type lambdas compile to
invokedynamic + LambdaMetafactory — no .class files on disk
- The JVM generates lightweight hidden classes at runtime for lambdas
Measured impact (JDK 25 GraalCE, JMH, Scala 2.13 + 3.8.2)
| Metric | Anonymous class | Factory + lambda |
|---|
| Steady-state throughput | baseline | tied (0.95-1.05x, within error) |
| Instance creation speed | baseline | 2.5-3.2x faster |
Per-type .class files | 1 per type | 0 per type |
Total .class files (N types) | 2 + N | constant |
See docs/research/anon-vs-lambda/ for full benchmark data, bytecode dumps, and JFR recordings.
Pattern: Monomorphic type classes (kind *)
For type classes with a fixed type parameter (Show[A], Eq[A], Hash[A], Encoder[A], etc.),
the factory accepts ordinary Function1/Function2 lambdas.
Factory definition
Place in the module's internal/runtime/ package (alongside existing runtime helpers):
// cats-derivation/internal/runtime/CatsDerivationFactories.scala
object CatsDerivationFactories {
def showInstance[A](f: A => String): cats.Show[A] = new cats.Show[A] {
def show(a: A): String = f(a)
}
def eqInstance[A](f: (A, A) => Boolean): cats.kernel.Eq[A] = new cats.kernel.Eq[A] {
def eqv(x: A, y: A): Boolean = f(x, y)
}
def hashInstance[A](hashFn: A => Int, eqvFn: (A, A) => Boolean): cats.kernel.Hash[A] =
new cats.kernel.Hash[A] {
def hash(x: A): Int = hashFn(x)
def eqv(x: A, y: A): Boolean = eqvFn(x, y)
}
}
Macro codegen change
Before (current — anonymous class per type):
cacheState.toValDefs.use { _ =>
Expr.quote {
new cats.kernel.Hash[A] {
def hash(value: A): Int = Expr.splice(hashCallFor(Expr.quote(value)))
def eqv(x: A, y: A): Boolean = Expr.splice(eqCallFor(Expr.quote(x), Expr.quote(y)))
}
}
}
After (factory + lambda):
cacheState.toValDefs.use { _ =>
Expr.quote {
CatsDerivationFactories.hashInstance[A](
(value: A) => Expr.splice(hashCallFor(Expr.quote(value))),
(x: A, y: A) => Expr.splice(eqCallFor(Expr.quote(x), Expr.quote(y)))
)
}
}
The generated bytecode uses invokedynamic for the lambdas instead of creating a new .class file.
Pattern: Polymorphic type classes (kind * -> *)
Polymorphic type classes like Functor[F[_]] have methods with their own type parameters
(def map[A, B](fa: F[A])(f: A => B): F[B]). A plain Function2 cannot represent this.
The erasure-based factory approach
Since the JVM erases type parameters, F[A] and F[B] have the same runtime representation.
The factory accepts a function typed for arbitrary witness types and casts at the boundary:
object CatsDerivationFactories {
// Arbitrary witness types for erasure-based polymorphic factories.
// These are never instantiated — they exist only to give the lambda a concrete type signature
// that the JVM erases to the same representation as any other type argument.
sealed trait W1
sealed trait W2
def functorInstance[F[_]](
mapFn: (F[W1], W1 => W2) => F[W2]
): cats.Functor[F] = new cats.Functor[F] {
def map[A, B](fa: F[A])(f: A => B): F[B] =
mapFn.asInstanceOf[(F[A], A => B) => F[B]].apply(fa, f)
}
}
Why this is safe: At the JVM level, F[W1] and F[A] are both Object (or the
erasure of F). The asInstanceOf cast is a no-op at runtime — it only satisfies the compiler's
type checker. This is the same erasure trick used throughout the polymorphic derivation code
(see ConsKMacrosImpl: hd.asInstanceOf[Any], tl.asInstanceOf[F[Any]]).
Macro codegen for polymorphic types — complete worked example
Before (anonymous class with method-level type params A, B):
protected def deriveFunctorForCaseClass[F[_]](
result: FunctorCaseClassResult[F],
runSafe: RunSafe[MIO]
)(implicit FCtor: Type.Ctor1[F]): Expr[cats.Functor[F]] =
Expr.quote {
new cats.Functor[F] {
def map[A, B](fa: F[A])(f: A => B): F[B] =
Expr.splice {
runSafe(deriveFunctorMapBody[F, A, B](
result.FCtor, result.directFieldSet,
Expr.quote(fa), Expr.quote(f)
)(Type.of[A], Type.of[B]))
}
}
}
After (factory + lambda with witness types W1, W2):
protected def deriveFunctorForCaseClass[F[_]](
result: FunctorCaseClassResult[F],
runSafe: RunSafe[MIO]
)(implicit FCtor: Type.Ctor1[F]): Expr[cats.Functor[F]] = {
import hearth.kindlings.catsderivation.internal.runtime.CatsDerivationFactories
Expr.quote {
CatsDerivationFactories.functorInstance[F] {
(fa: F[CatsDerivationFactories.W1], f: CatsDerivationFactories.W1 => CatsDerivationFactories.W2) =>
val _ = fa
val _ = f
Expr.splice {
runSafe(deriveFunctorMapBody[F, CatsDerivationFactories.W1, CatsDerivationFactories.W2](
result.FCtor, result.directFieldSet,
Expr.quote(fa), Expr.quote(f)
)(Type.of[CatsDerivationFactories.W1], Type.of[CatsDerivationFactories.W2]))
}
}
}
}
Key changes:
- Method-level type params
A, B are replaced by witness types W1, W2 everywhere
Type.of[A] / Type.of[B] become Type.of[CatsDerivationFactories.W1] / Type.of[CatsDerivationFactories.W2]
- The derive method (e.g.
deriveFunctorMapBody) is called with W1/W2 — it generates code
for F[W1] which is a valid case class. Due to erasure, the bytecode works for any A/B.
Three rules for polymorphic factories
Rule 1: Type.Ctor1[F] must be an implicit in scope
Hearth's Expr.quote on Scala 2 substitutes F[_] type constructors inside lambda parameter
type annotations only when Type.Ctor1[F] is available as an implicit. All polymorphic
macro methods already have implicit FCtor: Type.Ctor1[F], so the factory pattern works.
Without this implicit, Scala 2 generates a raw tree with Ident(TypeName("F")) that fails
at the macro expansion site with not found: type F.
Rule 2: W1/W2 must never be direct lambda parameter types
On Scala 3, sealed traits used as direct lambda parameter types generate JVM checkcast
instructions in lambda bridge methods. These fail at runtime when the actual argument is e.g.
Integer instead of W1.
Safe — W1/W2 as type arguments (erased to Object/Function1):
(fa: F[W1], f: W1 => W2) => ... // F[W1] erases to F, W1 => W2 erases to Function1
Unsafe — W1/W2 as direct parameter types (generates checkcast):
(a: W1) => ... // FAILS: checkcast to W1 at runtime
(b: W2, a: W1) => ... // FAILS: checkcast to W2, W1
Fix: Use Any for positions where values pass through as raw A/B:
// Factory definition — pure takes a raw value, so use Any:
def pureInstance[F[_]](pureFn: Any => F[W1]): alleycats.Pure[F] =
new alleycats.Pure[F] {
def pure[A](a: A): F[A] = pureFn.asInstanceOf[Any => F[A]].apply(a)
}
// foldLeft passes raw A and B values, so use Any:
def foldableInstance[F[_]](
foldLeftFn: (F[W1], Any, (Any, Any) => Any) => Any,
foldRightFn: (F[W1], Eval[Any], (Any, Eval[Any]) => Eval[Any]) => Eval[Any]
): cats.Foldable[F] = ...
// consK passes a raw A as head element:
def consKInstance[F[_]](consFn: (Any, F[W1]) => F[W1]): alleycats.ConsK[F] = ...
In the macro's Expr.quote block, the lambda parameter is Any and the splice body
builds the erased code accordingly.
Rule 3: Extra G[_] type constructors use type AnyF[A] = Any
Methods like traverse[G[_]: Applicative, A, B](fa: F[A])(f: A => G[B]): G[F[B]]
have an additional type constructor G[_] that the factory doesn't know about.
Use type AnyF[A] = Any for kind-correct erasure — it has kind * -> * so it can be used
with Applicative[AnyF] (unlike Applicative[Any] which fails on Scala 3 due to kind mismatch):
type AnyF[A] = Any // kind * -> *, erases G[_]
def traverseInstance[F[_]](
traverseFn: (F[W1], Any, Any) => Any, // erased: (F[A], A => G[B], Applicative[G]) => G[F[B]]
foldLeftFn: (F[W1], Any, (Any, Any) => Any) => Any,
foldRightFn: (F[W1], Eval[Any], (Any, Eval[Any]) => Eval[Any]) => Eval[Any]
): cats.Traverse[F] = new cats.Traverse[F] {
def traverse[G[_], A, B](fa: F[A])(f: A => G[B])(implicit G: cats.Applicative[G]): G[F[B]] =
traverseFn.asInstanceOf[(F[A], A => G[B], cats.Applicative[G]) => G[F[B]]].apply(fa, f, G)
// ...
}
Inside the macro splice, use cats.Applicative[CatsDerivationFactories.AnyF] when the
original code used cats.Applicative[G]:
// In the macro splice, the Applicative[G] implicit becomes Applicative[AnyF]:
implicit val GAnyF: Type[cats.Applicative[CatsDerivationFactories.AnyF]] = ...
Deciding which parameter positions need Any vs W1/W2
| Position | Use W1/W2 | Use Any |
|---|
F[_] applied: fa: F[W1] | Yes | — |
Function args: f: W1 => W2 | Yes | — |
F[_ => _] applied: ff: F[W1 => W2] | Yes | — |
Raw value: a: A (pure, cons) | — | Yes (a: Any) |
Accumulator: b: B (foldLeft) | — | Yes (b: Any) |
Fold function: (B, A) => B | — | Yes ((Any, Any) => Any) |
Extra HK: G[B], Applicative[G] | — | Yes (Any, use AnyF for kind) |
Alternative: Scala 3 polymorphic functions (NOT recommended)
Scala 3 supports polymorphic function types:
def functorInstance[F[_]](mapFn: [A, B] => (F[A], A => B) => F[B]): Functor[F]
This is type-safe but:
- Not available on Scala 2 (kindlings cross-compiles to 2.13)
- Requires different factory signatures per platform
- The erasure-based approach works identically on both platforms
Use the erasure-based approach for cross-compiled code. Reserve polymorphic functions for
Scala 3-only modules if they exist.
Alternative: Scala 2 macro-based FunctionK (NOT recommended for this use case)
Cats uses a macro to implement FunctionK (~>) that generates a SAM-compatible anonymous class.
See cats.arrow.FunctionKMacroMethods. This approach:
- Requires a separate macro per type class shape
- Adds macro complexity without measurable benefit over the erasure approach
- The erasure approach is simpler and proven safe by JVM specification
Inventory of migrated sites
All sites below have been migrated. Listed for reference when adding new type classes.
Monomorphic (kind *) — straightforward lambda factories
| Module | Type Class | Methods | File |
|---|
| cats-derivation | Show | 1: show | ShowMacrosImpl.scala:33 |
| cats-derivation | Eq | 1: eqv | EqMacrosImpl.scala:29 |
| cats-derivation | Order | 1: compare | OrderMacrosImpl.scala:29 |
| cats-derivation | PartialOrder | 1: partialCompare | PartialOrderMacrosImpl.scala:20 |
| cats-derivation | Hash | 2: hash, eqv | HashMacrosImpl.scala:151 |
| cats-derivation | Semigroup | 1: combine | SemigroupMacrosImpl.scala:25 |
| cats-derivation | CommutativeSemigroup | 1: combine | CommutativeSemigroupMacrosImpl.scala:16 |
| cats-derivation | Monoid | 2: combine, empty | MonoidMacrosImpl.scala:70 |
| cats-derivation | CommutativeMonoid | 2: combine, empty | CommutativeMonoidMacrosImpl.scala:16 |
| cats-derivation | Empty | 1: empty | EmptyMacrosImpl.scala:26 |
| circe-derivation | Encoder | 1: apply | EncoderMacrosImpl.scala:57 |
| circe-derivation | EncoderAsObject | 1: encodeObject | EncoderMacrosImpl.scala:95 |
| circe-derivation | Decoder | 1: apply | DecoderMacrosImpl.scala:130 |
| jsoniter-derivation | JsonValueCodec | 3: nullValue, decode, encode | CodecMacrosImpl.scala:115 |
| jsoniter-derivation | JsonCodec | 2+ | CodecMacrosImpl.scala:411 |
| fast-show-pretty | FastShowPretty | 1: render | FastShowPrettyMacrosImpl.scala:61 |
| pureconfig-derivation | ConfigWriter | 1: to | WriterMacrosImpl.scala:45 |
| pureconfig-derivation | ConfigReader | 1: from | ReaderMacrosImpl.scala:48 |
| yaml-derivation | YamlEncoder | 1: encode | EncoderMacrosImpl.scala:77 |
| yaml-derivation | YamlDecoder | 1: decode | DecoderMacrosImpl.scala:104 |
| xml-derivation | XmlEncoder | 1: encode | EncoderMacrosImpl.scala:91 |
| xml-derivation | XmlDecoder | 1-2: decode | DecoderMacrosImpl.scala:131,151 |
| tapir-schema-derivation | Schema | 1: schemaType | SchemaMacrosImpl.scala:75 |
| ubjson-derivation | UBJsonValueCodec | 3: nullValue, decode, encode | CodecMacrosImpl.scala:50 |
| avro-derivation | AvroSchemaFor | 1: schema | SchemaForMacrosImpl.scala:69 |
| avro-derivation | AvroDecoder | 1: decode | DecoderMacrosImpl.scala:92,411 |
| avro-derivation | AvroEncoder | 1: encode | EncoderMacrosImpl.scala:88 |
| sconfig-derivation | ConfigReader | 1: from | ReaderMacrosImpl.scala:40 |
| sconfig-derivation | ConfigWriter | 1: to | WriterMacrosImpl.scala:38 |
Polymorphic (kind * -> *) — erasure-based factories
| Module | Type Class | Methods | File |
|---|
| cats-derivation | Functor | 1: map | FunctorMacrosImpl.scala:46 |
| cats-derivation | Contravariant | 1: contramap | ContravariantMacrosImpl.scala:103 |
| cats-derivation | Invariant | 1: imap | InvariantMacrosImpl.scala:99 |
| cats-derivation | Apply | 2: map, ap | ApplyMacrosImpl.scala:86 |
| cats-derivation | Applicative | 3: pure, map, ap | ApplicativeMacrosImpl.scala:57 |
| cats-derivation | Foldable | 2: foldLeft, foldRight | FoldableMacrosImpl.scala:74 |
| cats-derivation | Traverse | 3: traverse, foldLeft, foldRight | TraverseMacrosImpl.scala:120 |
| cats-derivation | Reducible | 3: reduceLeftTo, foldLeft, foldRight | ReducibleMacrosImpl.scala:84 |
| cats-derivation | NonEmptyTraverse | 4+: nonEmptyTraverse, reduceLeftTo, ... | NonEmptyTraverseMacrosImpl.scala:141 |
| cats-derivation | SemigroupK | 1: combineK | SemigroupKMacrosImpl.scala:38 |
| cats-derivation | MonoidK | 2: empty, combineK | MonoidKMacrosImpl.scala:45 |
| cats-derivation | Pure | 1: pure | PureMacrosImpl.scala:78 |
| cats-derivation | EmptyK | 1: empty | EmptyKMacrosImpl.scala:34 |
| cats-derivation | NonEmptyAlternative | 3+: pure, ap, combineK | NonEmptyAlternativeMacrosImpl.scala:97 |
| cats-derivation | Alternative | 4+: pure, ap, empty, combineK | AlternativeMacrosImpl.scala:102 |
| cats-derivation | ConsK | 1: cons | ConsKMacrosImpl.scala:99 |
Implementation checklist
When migrating a type class to the factory pattern:
-
Define the factory method in the module's internal/runtime/ package
- For monomorphic: accept
FunctionN parameters matching method signatures
- For polymorphic: use
Witness1/Witness2 erasure approach
- Add
@publicInBinary annotation if the factory is in a different module than the macro call site
-
Ensure the factory's Type is available in the macro context
- Add the factory type to the
Types object in the macro impl
-
Change the macro codegen from new TypeClass[A] { ... } to Factory.instance[A](lambdas)
- The lambda bodies contain the same
Expr.splice(...) calls as before
- For polymorphic types, bind
A->Witness1, B->Witness2 at the factory call site
-
Clean and recompile — macros require clean rebuild after changes
-
Run tests — existing tests should pass without modification since runtime behavior is identical
-
Verify bytecode — confirm no per-type anonymous classes in javap output
Integration with existing patterns
Def-caching
The factory pattern works seamlessly with def-caching (ValDefsCache). The cached defs are
emitted by cacheState.toValDefs.use { _ => ... } outside the factory call, and the lambdas
reference them by name:
cacheState.toValDefs.use { _ =>
Expr.quote {
CatsDerivationFactories.hashInstance[A](
(value: A) => Expr.splice(hashCallFor(Expr.quote(value))),
(x: A, y: A) => Expr.splice(eqCallFor(Expr.quote(x), Expr.quote(y)))
)
}
}
Runtime helpers
Modules that already use runtime helpers (like CirceDerivationUtils.decoderFromFn) are already
partially using this pattern. The factory methods should be consolidated alongside those helpers.
@publicInBinary
If the factory is in a library module and called from macro-expanded code in user projects,
annotate it with @publicInBinary to prevent binary compatibility warnings. Since the factory
is part of the internal.runtime package, this signals it's an implementation detail that
must remain accessible.
Related skills