User Guide¶
This guide is a tutorial that starts from a working project and walks through one complete example, so you can see what kotlin-typeclasses adds on top of ordinary Kotlin context parameters.
If you do not already have a project, start with the pinned Quick Start in ../README.md. Once that builds, come back here and replace src/main/kotlin/demo/Main.kt with the file below.
What You Will Build¶
By the end of this guide you will have one runnable file that:
- defines a typeclass
Show<A> - publishes both concrete and generic
@Instancerules - uses companion-based associated lookup for
Box<A> - calls
summon<Show<A>>()and gets evidence synthesized by the compiler plugin
Complete Example¶
package demo
import one.wabbit.typeclass.Instance
import one.wabbit.typeclass.Typeclass
import one.wabbit.typeclass.summon
@Typeclass
interface Show<A> {
fun show(value: A): String
}
@Instance
object IntShow : Show<Int> {
override fun show(value: Int): String = value.toString()
}
@Instance
context(left: Show<A>, right: Show<B>)
fun <A, B> pairShow(): Show<Pair<A, B>> =
object : Show<Pair<A, B>> {
override fun show(value: Pair<A, B>): String =
"(" + left.show(value.first) + ", " + right.show(value.second) + ")"
}
data class Box<A>(val value: A) {
companion object {
@Instance
context(show: Show<A>)
fun <A> boxShow(): Show<Box<A>> =
object : Show<Box<A>> {
override fun show(value: Box<A>): String =
"Box(" + show.show(value.value) + ")"
}
}
}
context(_: Show<A>)
fun <A> render(value: A): String = summon<Show<A>>().show(value)
fun main() {
println(render(1))
println(render(1 to 2))
println(render(Box(1 to 2)))
}
Run it:
./gradlew run
Expected output:
1
(1, 2)
Box((1, 2))
Step 1: Mark The Typeclass Head¶
The @Typeclass annotation is what makes Show<A> participate in typeclass resolution.
@Typeclass
interface Show<A> {
fun show(value: A): String
}
That annotation matters because plain Kotlin context parameters are only explicit capability passing. The plugin adds a typeclass model on top: it knows which heads participate in search, where evidence can be published, and how prerequisites should be solved recursively.
For the full semantics of what counts as a typeclass, see Typeclass Model.
Step 2: Publish Evidence With @Instance¶
The tutorial uses two kinds of instance declarations:
- a concrete canonical instance as a top-level object
- a generic rule as a parameterless function whose context parameters are prerequisites
@Instance
object IntShow : Show<Int> {
override fun show(value: Int): String = value.toString()
}
@Instance
context(left: Show<A>, right: Show<B>)
fun <A, B> pairShow(): Show<Pair<A, B>> =
object : Show<Pair<A, B>> {
override fun show(value: Pair<A, B>): String =
"(" + left.show(value.first) + ", " + right.show(value.second) + ")"
}
Operationally, the compiler reads pairShow() as:
- if you can solve
Show<A> - and you can solve
Show<B> - then you can build
Show<Pair<A, B>>
That is the core mental model for generic @Instance functions.
Top-level instances are constrained; they must live in a legal owner file rather than an arbitrary orphan Instances.kt. When you start spreading instances across files or modules, use Instance Authoring for the placement rules.
Step 3: Let Companions Contribute Associated Rules¶
Box<A> publishes its own Show<Box<A>> rule from its companion:
data class Box<A>(val value: A) {
companion object {
@Instance
context(show: Show<A>)
fun <A> boxShow(): Show<Box<A>> =
object : Show<Box<A>> {
override fun show(value: Box<A>): String =
"Box(" + show.show(value.value) + ")"
}
}
}
This works because associated lookup is part of the programming model. For a goal like Show<Box<Pair<Int, Int>>>, the resolver is allowed to inspect Box's companion when searching for evidence.
That makes companion placement the normal home for rules that are primarily about one target type.
Step 4: Consume Evidence With summon()¶
The consumer in this tutorial is deliberately small:
context(_: Show<A>)
fun <A> render(value: A): String = summon<Show<A>>().show(value)
summon() itself is just a context helper:
context(value: T)
fun <T> summon(): T = value
The plugin is what makes summon<Show<A>>() behave like a typeclass request instead of a plain lexical context lookup. If the needed evidence is not already present directly, the compiler is allowed to search for matching @Instance rules and solve their prerequisites.
You could also write the consumer with an explicit named context parameter:
context(show: Show<A>)
fun <A> renderExplicit(value: A): String = show.show(value)
Both styles are normal. summon() is usually nicer when the evidence is only needed briefly inside the function body.
Step 5: Read The Example Like The Resolver¶
The three calls in main() show the recursive rule model:
render(1)resolves directly toIntShowrender(1 to 2)resolves topairShow(), which in turn requiresShow<Int>twice, both satisfied byIntShowrender(Box(1 to 2))resolves toBox.boxShow(), which requiresShow<Pair<Int, Int>>; that prerequisite resolves throughpairShow(), which then requiresIntShowtwice
For the full resolution order, ambiguity rules, and associated-scope model, see Typeclass Model.
What This Tutorial Did Not Cover¶
This guide stops at manual instance authoring on purpose. The next step depends on what you are trying to do:
- automatic derivation with
@Derive,@DeriveVia, or@DeriveEquiv: Derivation - builtin proof surfaces such as
Same,Subtype,KnownType, orTypeId: Proofs And Builtins - failed or ambiguous lookups, including the tracing default where bare
@DebugTypeclassResolutionmeansFAILURESrather thanINHERIT: Troubleshooting - publishing instances across module boundaries: Multi-Module Behavior