Multi-Module Behavior¶
This guide documents what kotlin-typeclasses does across source-set and dependency boundaries.
The short version:
- evidence can cross module boundaries
- visibility still matters
- derivation must have completed successfully in the producer before downstream code can use the exported result
- cross-module derivation is metadata-driven rather than ordinary generated-declaration publishing
- transient solver-only evidence is not automatically published
What Actually Crosses A Module Boundary¶
These surfaces are designed to survive binary publication:
- binary-retained annotations such as
@Typeclass,@Instance,@Derive,@DeriveVia, and@DeriveEquiv - public
@Instancedeclarations - compiler-emitted metadata annotations describing successful derived evidence
- public derivation metadata/runtime surfaces in
one.wabbit.typeclass
That is why the runtime library keeps these declarations public and why the compiler plugin re-discovers them in downstream compilations. For derivation specifically, downstream modules usually do not consume an ordinary generated declaration emitted by the upstream module; instead, they read compiler metadata from the dependency and reconstruct the corresponding derived rule in the consuming compilation.
Visibility Rules Still Apply¶
Downstream code only sees evidence that is actually visible.
In practice:
publicdependency instances can participate in downstream resolutioninternaldependency instances do not leak into downstream modulesprivatecompanion instances do not leak either
This applies to manual instances just like any other Kotlin declaration.
Public Companion Instances From Dependencies¶
Associated companion instances are a good cross-module default because they stay attached to the type they describe.
If a dependency publishes:
data class Box(val value: Int) {
companion object {
@Instance
val show: Show<Box> = ...
}
}
then downstream code asking for Show<Box> can resolve that instance normally.
Top-Level Instances Across Modules¶
Top-level instances also work across modules, but they still have to live in a legal owner file. The dependency boundary does not relax the normal placement rule.
For the canonical ownership rule and examples, see Instance Authoring.
Derived Evidence Across Dependencies¶
Derived evidence is exported only if derivation actually succeeded in the producer module.
That matters for sealed roots in particular:
- if a root
@Derivesucceeds, the producer emits metadata that lets downstream compilers reconstruct the derived root rule - if the producer's sealed hierarchy is incomplete or contains an unsupported case, that metadata is not exported downstream
- downstream use sites then fail just like any other missing-evidence case
This is all-or-nothing at the exported root level, so a partially valid hierarchy does not publish misleading derivation metadata.
@DeriveVia Across Dependencies¶
@DeriveVia can be compiled in one module and consumed in another.
Important supported shapes include:
- a producer module deriving an instance via an upstream waypoint type
- pinned
Isosingleton objects that live in an upstream dependency module - downstream consumers re-synthesizing the same derived rule from producer metadata
What does not get exported:
- transient local
Equivglue synthesized only while completing one@DeriveViarequest
So a producer may use local equivalence synthesis to finish one derivation, while downstream code still cannot later summon<Equiv<A, B>>() unless there is explicit exported Equiv evidence. The dependency boundary preserves the successful DeriveVia request through metadata rather than by publishing all intermediate solver artifacts.
@DeriveEquiv Across Dependencies¶
@DeriveEquiv is the explicit export surface for equivalence evidence.
If module B declares:
@DeriveEquiv(A::class)
data class B(val value: Int)
then downstream code can directly summon either orientation once it opts into the internal-support API:
@OptIn(InternalTypeclassApi::class)
val equiv = summon<Equiv<B, A>>()
@OptIn(InternalTypeclassApi::class)
val reverse = summon<Equiv<A, B>>()
Only that equivalence pair is exported, though both orientations are available. Unrelated targets do not become derivable just because some other @DeriveEquiv exists nearby.
As with other derivation surfaces, the exported shape is metadata-driven. Downstream compilers reconstruct the equivalence rules from dependency metadata rather than importing ordinary user-authored declarations with those types.
Consumer-Side Compiler Configuration¶
Published evidence and runtime annotations can cross module boundaries, but resolution still happens in the current compilation.
Downstream source code still needs:
- the
kotlin-typeclassesruntime on the classpath - the compiler plugin enabled for the downstream compilation
Optional builtins are also consumer-side configuration:
builtinKClassTypeclassbuiltinKSerializerTypeclasstypeclassTraceMode
Treat those as properties of the current build, not as declarations a dependency "exports".
Recommended Publishing Model¶
For reusable libraries:
- prefer public companion instances for type-specific evidence
- keep top-level instances in legal owner files
- avoid exporting both manual and derived evidence for the same head/target pair
- keep visibility intentional so downstream behavior is unsurprising