Wisteria
Easy, fast, transparent generic derivation of typeclass instances in Scala
Install / Use
/learn @propensive/WisteriaREADME
<img alt="GitHub Workflow" src="https://img.shields.io/github/actions/workflow/status/propensive/wisteria/main.yml?style=for-the-badge" height="24"> <img src="https://img.shields.io/discord/633198088311537684?color=8899f7&label=DISCORD&style=for-the-badge" height="24"> <img src="/doc/images/github.png" valign="middle">
Wisteria
Simple, fast and transparant generic derivation for typeclasses
Wisteria is a generic macro for automatic materialization of typeclasses for datatypes composed from product types (e.g. case classes) and coproduct types (e.g. enums). It supports recursively-defined datatypes out-of-the-box, and incurs no significant time-penalty during compilation.
Features
- derives typeclasses for case classes, case objects, sealed traits and enumerations
- offers a lightweight but typesafe syntax for writing derivations avoiding complex macro code
- builds upon Scala 3's built-in generic derivation
- works with recursive and mutually-recursive definitions
- supports parameterized ADTs (GADTs), including those in recursive types
- supports both consumer and producer typeclass interfaces
- fast at compiletime
- generates performant runtime code, without unnecessary runtime allocations
Availability
Wisteria 0.23.0 is available as a binary for Scala 3.5.0 and later, from Maven
Central. To include it in an sbt build, use
the coordinates:
libraryDependencies += "dev.soundness" % "wisteria-core" % "0.23.0"
Getting Started
Wisteria makes it easy to derive typeclass instances for product and sum types, by defining the rules for composition and delegation as simply as possible.
This is called generic derivation, and given a typeclass which provides some functionality on a type, it makes it possible to automatically extend that typeclass's functionality to all product types, so long as it is available for each of the product's fields; and optionally, to extend that typeclass's functionality to all sum types, so long as it is available for each of the sum's variants.
In other words, if we know how to do something to each field in a product, then we can do the same thing to the product itself; or if we can do something to each variant of a sum, then we can do the same thing to the sum itself.
Terminology
Sums and Products
In this documentation, and in Wisteria, we use the term product for types which are composed of a specific sequence of zero or more values of other types. Products include case classes, enumeration cases, tuples and singleton types, and the values from which they are composed are called fields. The fields for any given product have fixed types, appear in a canonical order and are labelled, though for tuples, the labels only indicate the field's position. Singletons have no fields.
Likewise, we use the term sum for types which represent a single choice from a specific and fixed set of disjoint types. Sum types include enumerations and sealed traits. Each of the disjoint types that together form a sum type is called a variant of the sum.
From a category-theoretical perspective, products and sums are each others' duals, and thus fields and variants are duals.
In the following example,
sealed trait Temporal
enum Month:
case Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
case class Date(day: Int, month: Month, year: Int) extends Temporal
case class Time(hour: Int, minute: Int)
case class DateTime(date: Date, time: Time) extends Temporal
we can say the following:
Temporalis a sum typeDateandDateTimeare variants ofTemporalDate,TimeandDateTimeare all product typesday,monthandyearare fields ofDatehourandminuteare fields ofTimedateandtimeare fields ofDateTimeMonthis a sum typeJanthrough toDecare all product types, all singletons, and all variants ofMonth- the type,
(Month, Int)(representing a month and a year) would be a product type, and a tuple
Typeclasses
A typeclass is a type (usually defined as a trait), whose instances provide
some functionality, through different implementations of an abstract method on
the typeclass, corresponding to different types which are specified in one of
the typeclass's type parameters. Instances are provided as contextual values
(givens), requested when needed through using parameters, and resolved
through contextual search (implicit search) at the callsite.
Where necessary, we distinguish clearly between a typeclass interface (the
generic trait and abstract method) and a typeclass instance (a given
definition which implements the aforesaid trait). The term typeclass alone
refers to the typeclass interface.
The exact structure of a typeclass interface varies greatly, but typically, a typeclass is a trait, with a single type parameter, and a single abstract method, where the type parameter appears either in the method's return type or in one or more of its parameters.
We call typeclasses whose type parameter appears in their abstract method's return type producers, because they produce new instances of the parameter type. Typeclasses whose type parameter appears in their abstract method's parameters, consumers because existing instances of the parameter type are given to them. (The term consumer shouldn't be misinterpreted to imply that any value is "used up" in applying the typeclass's functionality; it will be passed into a method, but will continue to exist for as long as references to it continue to exist.)
Producers may be covariant (indicated by a + before their type
parameter), and consumers may be contravariant (indicated by a - before their
type parameter). But either can be defined as invariant.
For example,
trait Size[ValueType]:
def size(value: ValueType): Double
is an invariant consumer typeclass interface for getting a representation (as a
double) of the size of an instance of ValueType. It might have instances
defined as:
object Size:
given Size[Boolean] = new Size[Boolean]:
def size(value: Boolean): Double = 1.0
given Size[Char]:
def size(value: Char): Double = 2.0
given Size[String] = _.length.toDouble
and even,
given [ElementType](using size: Size[ElementType]): Size[List[ElementType]] =
_.map(size.size(_)).sum
which constructs new typeclass instances for Lists on-demand, and which
requires a typeclass instance corresponding to the type of the List's
elements. Since Size is a single-abstract-method (SAM) type, it can be
implemented as a simple lambda corresponding to the abstract method.
Another typeclass example would be,
trait Default[+ValueType]:
def apply(): ValueType
which is a covariant producer typeclass interface.
Derivation
Wisteria lets us say, for a particular typeclass interface but for any product type, "if we have instances of the typeclass available for every field, then we can construct a typeclass instance for that product type", and provides the means to specify how they should be combined.
Dually, we can say that, for a particular typeclass instance but for any sum type, "if we have instances of the typeclass available for every variant of the sum, then we can construct a typeclass instance for that sum type", and provides the means to specify how the instances should be combined.
Naturally, fields and variants may themselves be products or sums, so generic derivation may be applied recursively.
Hence, if we define all our datatypes out of products and sum types of "simple" types, then for a particular typeclass interface, we can define typeclass instances for the simple types plus a generic derivation mechanism, and typeclass instances will effectively be available for every datatype.
Generic derivation for sum types is not always needed or even desirable, so we will start by exploring product derivation.
Deriving Products
Consumer Typeclasses
A typical example of a consumer typeclass is the Show typeclass. It provides
the functionality to take a value, and produce a string representation of that
value, and could be defined as,
trait Show[ValueType]:
def show(value: ValueType): Text
with an extension method to make it easier to apply the typeclass:
extension [ValueType: Show](value: ValueType)
def show: Text = summon[Show[ValueType]].show(value)
Generalizing over all products (and hence, all possible field types), our task is to define how a product type should be shown, if we're provided with the means to show each of its fields.
So, if we have Show instances for Ints and Texts, then we want to be able
to derive a Show instance for a type such as:
case class Person(name: Text, age: Int)
However, in the general case, we do not know how many fields there will be or what their types are, so we cannot rely on any of these details in our generic derivation definition.
To use Wisteria, we need to import the wisteria package,
import wisteria.*
and add the ProductDerivation trait to the companion object of the type we
want to define generic derivation for, along with the stub for the join
method, like so:
object Show extends ProductDerivation[Show]:
inline def join[DerivationType <: Product: ProductReflection]: Show[DerivationType] = ???
The signature of join must be defined exactly like this:
- it must be
inline - its type parameter must be a subtype of
Product - it must have a context bound on
ProductReflection - its return type must be an instance of the typeclass, parameterized on the method's type parameter
Given the return type, we know that we need to construct a new
Show[DerivationType] instance, so we can start with the definition,
object Show
