Derivation Checklist
Principles to follow when implementing a type class derivation macro with Hearth. Not every item applies to every type class — skip what genuinely doesn't make sense for your use case.
For detailed guidance on each topic, see Best Practices and the landing page example.
Entry Points
- Provide two entry points: one that returns a type class instance (
deriveTypeClass[A]) and one that inlines the derivation result directly (deriveInline[A]) — both should delegate to the same core logic - Separate runtime utilities (checked by MiMa) from macro-generated code
Type Coverage
- Handle primitives (Boolean, Byte, Short, Int, Long, Float, Double, Char, String) directly, without allocating a type class instance for each
- Handle optional types (
Option, etc.) - Handle collections (
List,Vector,Set,Array,Seq, etc.) and maps (Map[K, V]) - Handle case classes — iterate over fields, derive recursively for each field type
- Handle sealed traits and enums — pattern-match on subtypes, derive recursively for each
- Handle singletons (case objects, parameterless enum cases) as a dedicated rule
- Handle value types (classes extending
AnyVal) — unwrap and recurse on the underlying type - Handle tuples,
Eithers, named tuples (Scala 3), union types (Scala 3), and type-parametric types — unless the type class genuinely has no meaningful behavior for them
Implicit Resolution
- Always prefer user-provided implicits — check for existing instances before deriving from scratch, so users can override the default behavior
-
Ignore self-summoning — exclude the
derivedmethod (and, for subtype type classes, the parent library's auto-derivation methods) from implicit search to prevent infinite macro expansion- Additionally, exclude the current type class's type from implicit search, to prevent infinite recursion on
implicit val foo: TypeClass[Foo] = TypeClass.derive[Foo] - However, if you allow inlined derivation, like
val result = TypeClass.inlined(foo), do not exclude the current type from non-autoderived instances, it will let you keep the same behavior as the derivation was not inlined (using user-provided implicit if available, falling back to autoderivation otherwise)
- Additionally, exclude the current type class's type from implicit search, to prevent infinite recursion on
-
Cache every resolved implicit as a
lazy val— do not summon or allocate the same instance multiple times in the generated code - Handle generic contexts — derivation should work correctly when invoked inside a generic function where type parameters are not yet known
Recursion and Caching
- Derive recursively by walking the type tree internally, rather than relying on implicit resolution to recurse — this means users never need to learn about semi-automatic derivation or write
implicit val foo: TypeClass[Foo] = TypeClass.derive[Foo] - Cache derived logic for case classes and enums as local
defs — this keeps generated code compact, avoids the 64kB method size limit, and enables recursive data structures automatically - The combination of caching and macro-internal recursion should make recursive types like
case class Tree(children: List[Tree])work transparently withval tc = TypeClass.derive[Tree], with no user-side tricks
Error Reporting
- Aggregate errors across all fields and subtypes rather than failing on the first one
- Provide a detailed reason for each unsupported type — explain what went wrong (missing public constructor, private enum subtype, unsupported shape, etc.) so the user can fix all issues at once
- Include a hint in the error message telling the user how to enable debug logging
Introspection
- Allow inspecting the derivation logic and generated expression on demand, with no external tooling required
- Provide two mechanisms for enabling logging: an import-based approach (e.g.,
import mymodule.debug.logDerivation) and a scalac option (e.g.,-Xmacro-settings:myModule.logDerivation=true) -
Log every rule attempt, match, yield, cache hit, and recursive descent — wrap each derivation step in a named scope so the log tells the full story of what happened
- Since logging can be expensive, make all logging calls
lazy/by-name so they are only evaluated when the logging is enabled
- Since logging can be expensive, make all logging calls
Generated Code Quality
-
Suppress compiler warnings on generated code so users of
-Xfatal-warningsnever need@nowarnannotations for code they didn't write- Compile with
-Xfatal-warnings(at least on CI) and with as much linters enabled as possible - some of your users will surely have them turned on
- Compile with
-
Check runtime utilities with MiMa — see also the Library Author Guide
Cross-Compilation (Hearth-specific)
- Put shared macro logic in a mix-in trait (
src/main/scala/) that extendsMacroCommons - Write Scala 2 and Scala 3 adapters in platform-specific source directories
- Use Cross Quotes (
Expr.quote/Expr.splice) for quoting in shared code - Test against both Scala versions
Testing
- Test each supported type category: primitives, value types, optionals, collections, maps, case classes (zero-field, single-field, nested), sealed traits and enums (with case objects and case classes), tuples, recursive data structures
- Test Scala 3-only types (Scala 3
enumdeclarations, named tuples, opaque types) insrc/test/scala-3/(sbt-projectmatrix/sbt 2.0 convention) - Test that user-provided implicits are preferred over derived ones
- Test that unsupported types produce clear compile-time error messages with all reasons aggregated
- Test that recursive types work transparently — no
lazy val, no manual instance, no tricks - Test that semiautomatic derivation does not cause infinite recursion