Skip to content

Commit 483fa0b

Browse files
committed
fix(migration): use format:off to preserve Scala 2 macro syntax
1 parent f070ddb commit 483fa0b

6 files changed

Lines changed: 672 additions & 38 deletions

File tree

‎.sisyphus/boulder.json‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"active_plan": "/Users/nabil_abdel-hafeez/zio-repos/zio-blocks/.sisyphus/plans/migration-intersection-types.md",
3+
"started_at": "2026-02-05T07:10:09.878Z",
4+
"session_ids": [
5+
"ses_3fc335044ffeSVftcfPVKtg1Xd"
6+
],
7+
"plan_name": "migration-intersection-types"
8+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Learnings: Literal Type Preservation in Scala 3 Macros
2+
3+
## Problem
4+
When using `.asType` on an `AndType` containing a `ConstantType(StringConstant("fieldName"))`, the literal type gets widened to `String`. This breaks type-level tracking of field names in the migration builder.
5+
6+
## Root Cause
7+
Scala 3's `.asType` method normalizes types, and literal types in intersection types get widened to their base type. For example:
8+
```scala
9+
val fieldNameType = ConstantType(StringConstant("age"))
10+
val singletonType = Symbol.requiredClass("scala.Singleton").typeRef
11+
val singletonBound = AndType(fieldNameType, singletonType)
12+
13+
singletonBound.asType match {
14+
case '[newTp] => // newTp is String, NOT "age" & Singleton!
15+
}
16+
```
17+
18+
## Solution
19+
Use a phantom type wrapper `FieldName[N <: String & Singleton]` to preserve literal types:
20+
```scala
21+
sealed trait FieldName[N <: String & Singleton]
22+
23+
// In macro:
24+
val fieldNameType = ConstantType(StringConstant("age"))
25+
val fieldNameWrapped = TypeRepr.of[FieldName].appliedTo(fieldNameType)
26+
val newTPType = AndType(TypeRepr.of[TP], fieldNameWrapped)
27+
28+
newTPType.asType match {
29+
case '[newTp] => // newTp correctly contains FieldName["age"]
30+
}
31+
```
32+
33+
## Why This Works
34+
1. `FieldName[N]` is a proper type constructor
35+
2. When using `TypeRepr.of[FieldName].appliedTo(fieldNameType)`, the type argument is preserved
36+
3. The extraction logic can pattern match on `AppliedType(tycon, List(field))` to get the literal
37+
38+
## Key Insight
39+
Direct literal types in intersection types don't survive `.asType`, but type constructor applications do. This is a fundamental limitation of Scala 3 macro type handling.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Decisions: Migration Intersection Types Refactoring
2+
3+
## Decision 1: Use FieldName Wrapper Type
4+
5+
**Context**: Direct intersection types with literal types (`Any & "fieldName"`) don't preserve the literal type through Scala 3's macro system.
6+
7+
**Decision**: Introduce a `FieldName[N <: String & Singleton]` sealed trait to wrap literal types.
8+
9+
**Rationale**:
10+
- Preserves literal type information through the type system
11+
- Works with both Scala 3.3.7 and 3.7.4
12+
- Minimal runtime overhead (phantom type)
13+
- Clean extraction pattern in validation macros
14+
15+
**Alternatives Considered**:
16+
1. Keep using tuples - rejected because the goal was to simplify with intersection types
17+
2. Use match types - more complex and not necessary
18+
3. Direct `AndType` construction - doesn't preserve literal types
19+
20+
## Decision 2: Use `Any` as Empty Type Instead of `EmptyTuple`
21+
22+
**Context**: Need a base type for the intersection chain.
23+
24+
**Decision**: Use `Any` as the starting type for `SourceHandled` and `TargetProvided`.
25+
26+
**Rationale**:
27+
- `Any & X` simplifies to `X` in Scala's type system
28+
- Works naturally with intersection types
29+
- No special handling needed for the empty case
30+
31+
## Decision 3: Construct Types at TypeRepr Level
32+
33+
**Context**: Need to build intersection types in macros.
34+
35+
**Decision**: Use `TypeRepr.of[FieldName].appliedTo(fieldNameType)` and `AndType(...)` to construct types.
36+
37+
**Rationale**:
38+
- Preserves literal type information
39+
- Works with `asType` to get a usable type variable
40+
- Consistent pattern across all macro methods
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Learnings: Migration Intersection Types Refactoring
2+
3+
## Key Challenge: Literal Type Widening in Scala 3 Macros
4+
5+
When using `ConstantType(StringConstant(fieldName)).asType` in Scala 3 macros, the resulting type variable gets widened to `String` instead of preserving the literal type (e.g., `"age"`).
6+
7+
### What Didn't Work
8+
9+
1. **Direct intersection with literal type**: `TP & fn` where `fn` is captured from `asType` - the literal type gets widened to `String`.
10+
11+
2. **Constructing AndType directly**: `AndType(TypeRepr.of[TP], fieldNameType).asType` - same widening issue.
12+
13+
3. **Type alias with intersection**: `type AddField[T, F] = T & F` - the literal type still gets widened when passed through the type system.
14+
15+
### What Worked: Wrapper Type Pattern
16+
17+
The solution is to wrap the literal type in a phantom type that preserves it:
18+
19+
```scala
20+
sealed trait FieldName[N <: String & Singleton]
21+
type AddField[T, F <: String & Singleton] = T & FieldName[F]
22+
```
23+
24+
In the macros, construct the type as:
25+
```scala
26+
val fieldNameType = ConstantType(StringConstant(fieldName))
27+
val fieldNameWrapped = TypeRepr.of[FieldName].appliedTo(fieldNameType)
28+
val newTPType = AndType(TypeRepr.of[TP], fieldNameWrapped)
29+
```
30+
31+
The `FieldName[N]` wrapper preserves the literal type because:
32+
1. The literal type is stored as a type parameter to `FieldName`
33+
2. When `asType` is called on the `AppliedType`, the type parameter is preserved
34+
3. The validation macro can extract the literal type by pattern matching on `AppliedType(tycon, List(field))` where `tycon.typeSymbol.name == "FieldName"`
35+
36+
### Why Tuples Worked Originally
37+
38+
The original tuple-based approach (`Tuple.Concat[TP, fn *: EmptyTuple]`) worked because:
39+
1. `Tuple.Concat` is a type-level computation that the compiler evaluates lazily
40+
2. The literal type `fn` is used as a type argument in a type expression
41+
3. The compiler preserves literal types in type-level computations
42+
43+
### Extraction Pattern
44+
45+
To extract field names from the intersection type:
46+
```scala
47+
def extract(tpe: TypeRepr): Set[String] = tpe.dealias match {
48+
case AndType(left, right) => extract(left) ++ extract(right)
49+
case ConstantType(StringConstant(s)) => Set(s)
50+
case t if t =:= TypeRepr.of[Any] => Set.empty
51+
case AppliedType(tycon, List(field)) if tycon.typeSymbol.name == "FieldName" =>
52+
extract(field)
53+
case _ => Set.empty
54+
}
55+
```

0 commit comments

Comments
 (0)