In this blog post, we'll dive into a powerful implementation of a type-safe heterogeneous map in Scala 3. We'll explore how to create a TypedHMap
(a slight twist on the original HMap
name) that allows keys and values of different types while maintaining type safety, and pair it with a dynamic interface for an intuitive, case-class-like experience. Along the way, we'll unpack the Scala 3 concepts that make this possible and showcase its power with practical examples.
In Scala, a standard Map
requires all keys to share one type (e.g., String
) and all values to share another (e.g., Int
), as in Map[String, Int]
. But what if you need a map where each key-value pair can have its own types—like "name" -> String
, "age" -> Int
, and "active" -> Boolean
—all within the same map? This is where a heterogeneous map comes in. Unlike a Map[Any, Any]
, which sacrifices type safety, our goal is a map that enforces type correctness at compile time while allowing this flexibility.
Traditionally, achieving this in Scala required complex libraries like Shapeless or macro-based solutions. These approaches, while effective, often added overhead or complexity. Scala 3, however, introduces features like opaque types, match types, and improved support for the Dynamic
trait, enabling us to build a clean, library-free solution.
Let’s break down the key Scala 3 features we’ll use:
- Opaque Types: These allow us to hide the underlying implementation of a type (e.g.,
Map[Any, Any]
) while providing a safer, restricted interface. - Match Types: A powerful way to compute types based on pattern matching, perfect for tracking key-value pairs in our map.
- Dynamic Trait: Enables dynamic method invocation (e.g.,
obj.name
), giving us a syntax reminiscent of case classes without predefining fields.
Our TypedHMap
is a heterogeneous map where the type system tracks the key-value pairs using a tuple type T
. Here’s the core implementation:
package example
import scala.annotation.implicitNotFound
import scala.language.{dynamics, implicitConversions}
object TypedHMap:
// Opaque type hiding Map[Any, Any]
opaque type M[T <: Tuple] = Map[Any, Any]
// Extension methods for adding, getting, and deleting entries
extension [T <: Tuple](m: M[T])
def add[K <: Singleton, V](k: K, v: V)(using NoMatch[T, K] =:= true): M[(K, V) *: T] =
m + (k -> v)
def get[K <: Singleton](k: K)(using NoMatch[T, K] =:= false): GetMatch[T, K] =
m(k).asInstanceOf[GetMatch[T, K]]
def delete[K <: Singleton](k: K)(using NoMatch[T, K] =:= false): M[DropMatch[T, K]] =
m - k
// Handler for updating or adding entries
trait UpdateHandler[T <: Tuple, K <: Singleton, V]:
type Out <: Tuple
def apply(m: M[T], k: K, v: V): M[Out]
implicit def updateExisting[T <: Tuple, K <: Singleton, V](using
ev1: NoMatch[T, K] =:= false,
ev2: GetMatch[T, K] =:= V
): UpdateHandler[T, K, V] { type Out = T } = new UpdateHandler[T, K, V]:
type Out = T
def apply(m: M[T], k: K, v: V): M[T] = m + (k -> v)
implicit def addNew[T <: Tuple, K <: Singleton, V](using
ev: NoMatch[T, K] =:= true
): UpdateHandler[T, K, V] { type Out = (K, V) *: T } = new UpdateHandler[T, K, V]:
type Out = (K, V) *: T
def apply(m: M[T], k: K, v: V): M[(K, V) *: T] = m.add(k, v)
// Starting point: an empty map
val empty: M[EmptyTuple] = Map.empty
// Match types to manage the tuple T
type NoMatch[Tup <: Tuple, K] = Tup match
case (K *: ? *: EmptyTuple) *: ? => false
case (? *: ? *: EmptyTuple) *: rest => NoMatch[rest, K]
case EmptyTuple => true
type GetMatch[Tup <: Tuple, K] = Tup match
case EmptyTuple => Nothing
case (K *: t *: EmptyTuple) *: ? => t
case (? *: ? *: EmptyTuple) *: rest => GetMatch[rest, K]
type DropMatch[Tup, K] <: Tuple = Tup match
case EmptyTuple => EmptyTuple
case (K *: ? *: EmptyTuple) *: rest => rest
case (k *: t *: EmptyTuple) *: rest => (k *: t *: EmptyTuple) *: DropMatch[rest, K]
- Opaque Type
M[T]
: Internally, it’s aMap[Any, Any]
, but the type parameterT
—a tuple of(key, value)
pairs like(("name", String), ("age", Int))
—tracks the contents. The opaque nature ensures users interact only through our safe methods. - Extension Methods:
add[K, V]
: Adds a key-value pair if the key isn’t present (NoMatch[T, K] =:= true
), updatingT
to(K, V) *: T
.get[K]
: Retrieves a value if the key exists (NoMatch[T, K] =:= false
), returning the associated type viaGetMatch
.delete[K]
: Removes a key-value pair if the key exists, adjustingT
withDropMatch
.
- Match Types:
NoMatch[T, K]
: Returnstrue
ifK
isn’t a key inT
,false
otherwise.GetMatch[T, K]
: Extracts the value type paired withK
.DropMatch[T, K]
: Computes the new tuple type after removing(K, V)
.
For example, starting with M[EmptyTuple]
, calling add("name", "John")
yields M[("name", String) *: EmptyTuple]
.
To make our map feel like a case class, we wrap it in a DynamicRecord
class using the Dynamic
trait:
class DynamicRecord[T <: Tuple](hmap: TypedHMap.M[T]) extends Dynamic:
def selectDynamic[K <: Singleton](k: K)(using
@implicitNotFound("Field ${K} does not exist in this record")
ev: TypedHMap.NoMatch[T, K] =:= false
): TypedHMap.GetMatch[T, K] = hmap.get(k)(using ev)
def updateDynamic[K <: Singleton, V](k: K)(v: V)(using
handler: TypedHMap.UpdateHandler[T, K, V]
): DynamicRecord[handler.Out] = new DynamicRecord(handler(hmap, k, v))
selectDynamic
: Enables syntax likerecord.name
, ensuring at compile time that the key exists.updateDynamic
: Supports assignment likerecord.age = 31
, either updating an existing key or adding a new one, returning a new record with the updated type.
Imagine configuring a server with dynamic settings:
val config = TypedHMap.empty
.add("host", "localhost")
.add("port", 8080)
.add("secure", false)
val configRecord = new DynamicRecord(config)
println(configRecord.host) // "localhost"
println(configRecord.port) // 8080
println(configRecord.secure) // false
val updatedConfig = configRecord.timeout = 5000
println(updatedConfig.timeout) // 5000
// Compile error if accessing unknown field
// println(configRecord.user) // Does not compile
The types are preserved: configRecord.host
is String
, configRecord.port
is Int
, etc.
Suppose we’re modeling a row from a database with varying column types:
case class Address(city: String, zip: Int)
val row = TypedHMap.empty
.add("id", 1)
.add("name", "Alice")
.add("address", Address("New York", 10001))
val rowRecord = new DynamicRecord(row)
println(rowRecord.id) // 1
println(rowRecord.name) // "Alice"
println(rowRecord.address.city) // "New York"
val updatedRow = rowRecord.name = "Bob"
println(updatedRow.name) // "Bob"
val deletedRow = updatedRow.hmap.delete("address")
val newRecord = new DynamicRecord(deletedRow)
// newRecord.address // Would not compile
This preserves type safety even with complex value types like Address
.
In testing, create a mock object with specific fields:
val mock = TypedHMap.empty
.add("status", "success")
.add("code", 200)
val mockRecord = new DynamicRecord(mock)
val status: String = mockRecord.status // "success"
val code: Int = mockRecord.code // 200
Attempting mockRecord.error
would fail at compile time, ensuring correctness.
Keys must be Singleton
types (e.g., "name"
as "name".type
, a subtype of String
). This restricts keys to compile-time literals, enabling type-level tracking.
Consider a simpler match type:
type First[T <: Tuple] = T match
case h *: _ => h
case EmptyTuple => Nothing
val x: First[("a", Int) *: EmptyTuple] = "a" // Works
In TypedHMap
, NoMatch
traverses T
to check for a key, GetMatch
finds the value type, and DropMatch
rebuilds the tuple without the key.
- Singleton Keys: Only compile-time known values (literals) can be keys, not runtime variables.
- Performance: The
Dynamic
trait may introduce a slight overhead due to method dispatch. - Complexity: Match types can be tricky to debug if the tuple structure gets complex.
TypedHMap
and DynamicRecord
offer a compelling way to blend dynamic field access with static type safety in Scala 3. By leveraging opaque types, match types, and the Dynamic
trait, we’ve built a tool that’s both flexible and robust—ideal for configurations, data modeling, or testing. While it has limitations, its elegance and power make it a standout example of Scala 3’s advanced type system. Try extending it—perhaps add a keys
method or integrate it into a DSL—and see where it takes you!