Skip to content

Commit ef41edc

Browse files
ignacio-hivemindisaias-b
authored andcommitted
Implement opaque fn typeclass
1 parent 3916158 commit ef41edc

File tree

12 files changed

+1127
-64
lines changed

12 files changed

+1127
-64
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
.bloop
33
.bsp
44
.metals
5+
.idea
6+
.scala-build
57
target
68
project/project
79
project/target

ai-docs/opaque-typeclass.spec.md

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# Opaque Type Encoder Specification
2+
3+
## Problem Statement
4+
5+
Currently, the `PromptEncoder` typeclass creates runtime wrapper objects for each encoder instance. This has several implications:
6+
7+
1. **Runtime Overhead**: Each encoder is a wrapper around a function `A => Prompt`
8+
2. **Memory Allocation**: Creating encoder instances allocates objects
9+
3. **Indirection**: Method calls through trait interfaces add overhead
10+
4. **Compilation Time**: Complex typeclass resolution chains
11+
12+
## Design Goal: Zero-Cost Abstraction
13+
14+
The goal is to explore using **opaque types** to eliminate runtime overhead while maintaining:
15+
- **Type Safety**: Compile-time guarantees about encoder availability
16+
- **Ergonomics**: Good error messages when implicits are missing
17+
- **Composability**: Support for contravariant operations and combinators
18+
- **Developer Experience**: Clear, actionable compiler errors
19+
20+
## Current Implementation Analysis
21+
22+
### Current PromptEncoder:
23+
```scala
24+
trait PromptEncoder[A]:
25+
def encode(a: A): Prompt
26+
27+
object PromptEncoder:
28+
given PromptEncoder[String] = x => Literal(x)
29+
// Runtime: creates object with vtable lookup
30+
```
31+
32+
**Issues:**
33+
- Each `given` creates a wrapper object
34+
- Method calls go through virtual dispatch
35+
- Memory allocation for each encoder instance
36+
37+
## Proposed Solution: Opaque Function Types
38+
39+
### Option 1: Direct Opaque Function
40+
```scala
41+
opaque type PromptEncoder[A] = A => Prompt
42+
43+
object PromptEncoder:
44+
// Zero-cost: just the function, no wrapper
45+
given PromptEncoder[String] = x => Literal(x)
46+
47+
extension [A](encoder: PromptEncoder[A])
48+
def encode(a: A): Prompt = encoder(a)
49+
```
50+
51+
### Option 2: Opaque Type with Phantom Type Parameter
52+
```scala
53+
opaque type PromptEncoder[A] <: (A => Prompt) = A => Prompt
54+
55+
object PromptEncoder:
56+
def apply[A](f: A => Prompt): PromptEncoder[A] = f
57+
58+
given PromptEncoder[String] = apply(x => Literal(x))
59+
60+
extension [A](encoder: PromptEncoder[A])
61+
def encode(a: A): Prompt = encoder(a)
62+
```
63+
64+
### Option 3: Opaque Type with Smart Constructor
65+
```scala
66+
opaque type PromptEncoder[A] = A => Prompt
67+
68+
object PromptEncoder:
69+
inline def from[A](f: A => Prompt): PromptEncoder[A] = f
70+
71+
given string: PromptEncoder[String] = from(x => Literal(x))
72+
given prompt: PromptEncoder[Prompt] = from(identity)
73+
```
74+
75+
## Ergonomics Analysis
76+
77+
### Error Messages Comparison
78+
79+
**Current Trait-Based Approach:**
80+
```scala
81+
val x: String = "hello"
82+
val prompt = x.toPrompt(validator) // Missing PromptEncoder[String]
83+
```
84+
Error: `No given instance of type PromptEncoder[String] was found`
85+
86+
**Opaque Type Approach:**
87+
```scala
88+
val x: String = "hello"
89+
val prompt = x.toPrompt(validator) // Missing PromptEncoder[String]
90+
```
91+
Expected Error: `No given instance of type PromptEncoder[String] was found`
92+
93+
### @implicitNotFound Annotation Support
94+
95+
**Question**: Do `@implicitNotFound` annotations work with opaque types?
96+
97+
**Test Cases to Explore:**
98+
99+
#### Test 1: Basic @implicitNotFound on Opaque Type
100+
```scala
101+
import scala.annotation.implicitNotFound
102+
103+
@implicitNotFound("No PromptEncoder available for type ${A}. Please provide an encoder or import PromptEncoder.unsafe._")
104+
opaque type PromptEncoder[A] = A => Prompt
105+
```
106+
107+
#### Test 2: @implicitNotFound on Companion Object Methods
108+
```scala
109+
opaque type PromptEncoder[A] = A => Prompt
110+
111+
object PromptEncoder:
112+
@implicitNotFound("Custom encoder message for ${A}")
113+
def summon[A](using encoder: PromptEncoder[A]): PromptEncoder[A] = encoder
114+
```
115+
116+
#### Test 3: @implicitNotFound on Extension Methods
117+
```scala
118+
extension [A](a: A)
119+
@implicitNotFound("Cannot convert ${A} to Prompt. Missing PromptEncoder[${A}]")
120+
def toPrompt(validator: PromptValidator[A])(using encoder: PromptEncoder[A]): Prompt =
121+
Value(a, validator, encoder)
122+
```
123+
124+
## Performance Analysis
125+
126+
### Runtime Characteristics
127+
128+
**Current Trait Implementation:**
129+
- ✅ Type safety at compile time
130+
- ❌ Virtual method dispatch overhead
131+
- ❌ Object allocation for each encoder
132+
- ❌ Memory overhead for trait objects
133+
134+
**Opaque Function Implementation:**
135+
- ✅ Type safety at compile time
136+
- ✅ Direct function call (no vtable)
137+
- ✅ Zero allocation overhead
138+
- ✅ Functions are values, not objects
139+
140+
### Compilation Impact
141+
142+
**Questions to Investigate:**
143+
1. **Specialization**: Do opaque function types specialize better?
144+
2. **Inlining**: Can the compiler inline through opaque boundaries?
145+
3. **Typeclass Resolution**: Is resolution faster with opaque types?
146+
4. **Binary Size**: Does the compiled output differ significantly?
147+
148+
## Composability Analysis
149+
150+
### Contravariant Semigroupal Support
151+
152+
**Current Implementation:**
153+
```scala
154+
given contravariantSemigroupal: cats.ContravariantSemigroupal[PromptEncoder] with
155+
def contramap[A, B](fa: PromptEncoder[A])(f: B => A): PromptEncoder[B] = ???
156+
def product[A, B](fa: PromptEncoder[A], fb: PromptEncoder[B]): PromptEncoder[(A, B)] = ???
157+
```
158+
159+
**Opaque Type Challenge:**
160+
```scala
161+
opaque type PromptEncoder[A] = A => Prompt
162+
163+
// How to implement ContravariantSemigroupal for opaque types?
164+
given contravariantSemigroupal: cats.ContravariantSemigroupal[PromptEncoder] with
165+
def contramap[A, B](fa: PromptEncoder[A])(f: B => A): PromptEncoder[B] =
166+
(b: B) => fa(f(b)) // This should work!
167+
168+
def product[A, B](fa: PromptEncoder[A], fb: PromptEncoder[B]): PromptEncoder[(A, B)] =
169+
(pair: (A, B)) =>
170+
val (a, b) = pair
171+
fa(a) |+| fb(b) // Requires Semigroup[Prompt]
172+
```
173+
174+
## Migration Strategy
175+
176+
### Phase 1: Proof of Concept
177+
1. Create opaque type version alongside current implementation
178+
2. Test error message quality with @implicitNotFound
179+
3. Benchmark performance differences
180+
4. Verify cats typeclass instance compatibility
181+
182+
### Phase 2: A/B Testing
183+
1. Implement same DSL functionality with both approaches
184+
2. Compare developer experience in real usage
185+
3. Measure compilation times and binary size
186+
4. Test IDE support and error highlighting
187+
188+
### Phase 3: Decision & Migration
189+
1. Choose approach based on empirical evidence
190+
2. Create migration guide if opaque types are superior
191+
3. Deprecate old implementation gradually
192+
4. Update documentation and examples
193+
194+
## Experimental Test Cases
195+
196+
### Test 1: Basic Functionality
197+
```scala
198+
// Should work identically to current implementation
199+
opaque type TestEncoder[A] = A => Prompt
200+
201+
given TestEncoder[String] = (s: String) => Literal(s)
202+
given TestEncoder[Int] = (i: Int) => Literal(i.toString)
203+
204+
val stringPrompt: Prompt = summon[TestEncoder[String]]("hello")
205+
val intPrompt: Prompt = summon[TestEncoder[Int]](42)
206+
```
207+
208+
### Test 2: Error Message Quality
209+
```scala
210+
// Should produce helpful error message
211+
def needsEncoder[A](a: A)(using TestEncoder[A]): Prompt = ???
212+
213+
needsEncoder(List(1, 2, 3)) // No TestEncoder[List[Int]] - what error?
214+
```
215+
216+
### Test 3: Composition
217+
```scala
218+
// Should compose like current encoders
219+
case class Person(name: String, age: Int)
220+
221+
given TestEncoder[Person] = (p: Person) =>
222+
summon[TestEncoder[String]](p.name) |+| summon[TestEncoder[Int]](p.age)
223+
```
224+
225+
### Test 4: Performance Benchmark
226+
```scala
227+
// Compare allocation and timing
228+
def benchmarkCurrent(data: List[String]): List[Prompt] =
229+
data.map(summon[PromptEncoder[String]].encode)
230+
231+
def benchmarkOpaque(data: List[String]): List[Prompt] =
232+
data.map(summon[TestEncoder[String]])
233+
```
234+
235+
## Open Questions
236+
237+
1. **@implicitNotFound Behavior**: Do annotations work on opaque types?
238+
2. **IDE Support**: How do IDEs display opaque type errors?
239+
3. **Debugging**: Can we debug through opaque type boundaries?
240+
4. **Specialization**: Does Scala 3 specialize opaque function types?
241+
5. **Variance**: How do opaque types interact with variance annotations?
242+
6. **Binary Compatibility**: Are opaque types binary compatible across versions?
243+
244+
## Success Criteria
245+
246+
**Must Have:**
247+
- ✅ Zero runtime overhead compared to current implementation
248+
- ✅ Equivalent type safety guarantees
249+
- ✅ Compatible with cats typeclass instances
250+
- ✅ Clear error messages for missing encoders
251+
252+
**Nice to Have:**
253+
- ✅ Better error messages than current implementation
254+
- ✅ Faster compilation times
255+
- ✅ Smaller binary size
256+
- ✅ Better IDE integration
257+
258+
**Deal Breakers:**
259+
- ❌ Worse error messages than current approach
260+
- ❌ Loss of composability features
261+
- ❌ Breaking changes to public API
262+
- ❌ Incompatibility with ecosystem libraries
263+
264+
## Conclusion
265+
266+
Opaque types offer a promising path to zero-cost abstractions for the encoder typeclass. The main unknowns are around error message quality and @implicitNotFound annotation support.
267+
268+
**Recommendation**: Implement a proof-of-concept to empirically test error messages, performance, and developer ergonomics before making any migration decisions.

0 commit comments

Comments
 (0)