Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save EtaCassiopeia/e2f7da511ae84a01dd4cc036028b2417 to your computer and use it in GitHub Desktop.
Save EtaCassiopeia/e2f7da511ae84a01dd4cc036028b2417 to your computer and use it in GitHub Desktop.

Exploring Type-Safe Dynamic Maps in Scala 3

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.

What is a Heterogeneous Map?

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.

Challenges in Scala

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.

Scala 3 Features in Action

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.

Introducing TypedHMap

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]

How It Works

  • Opaque Type M[T]: Internally, it’s a Map[Any, Any], but the type parameter T—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), updating T to (K, V) *: T.
    • get[K]: Retrieves a value if the key exists (NoMatch[T, K] =:= false), returning the associated type via GetMatch.
    • delete[K]: Removes a key-value pair if the key exists, adjusting T with DropMatch.
  • Match Types:
    • NoMatch[T, K]: Returns true if K isn’t a key in T, false otherwise.
    • GetMatch[T, K]: Extracts the value type paired with K.
    • 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].

Dynamic Access with DynamicRecord

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 like record.name, ensuring at compile time that the key exists.
  • updateDynamic: Supports assignment like record.age = 31, either updating an existing key or adding a new one, returning a new record with the updated type.

Showcasing the Power: Examples

1. Building a Configuration Object

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.

2. Representing a Database Row

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.

3. Dynamic Test Mock

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.

Understanding the Magic

Singleton Types

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.

Match Types Explained

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.

Limitations

  • 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.

Conclusion

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment