SkillAgentSearch skills...

Wisteria

Easy, fast, transparent generic derivation of typeclass instances in Scala

Install / Use

/learn @propensive/Wisteria

README

<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:

  • Temporal is a sum type
  • Date and DateTime are variants of Temporal
  • Date, Time and DateTime are all product types
  • day, month and year are fields of Date
  • hour and minute are fields of Time
  • date and time are fields of DateTime
  • Month is a sum type
  • Jan through to Dec are all product types, all singletons, and all variants of Month
  • 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 
View on GitHub
GitHub Stars25
CategoryDevelopment
Updated4mo ago
Forks3

Languages

Scala

Security Score

77/100

Audited on Nov 23, 2025

No findings