git.iko.soy/rowtype-yoga/purescript-yoga-om-layer
on main help sign in
243 lines 8.6 kB history raw
1# yoga-om-layer (Proof of Concept)
3**Type-level dependency deduplication for PureScript's Om monad**
5## What This Proves
7This is a proof of concept demonstrating that **PureScript's row types and `Row.Nub` can automatically deduplicate shared dependencies at compile time**, similar to ZLayer in Scala's ZIO.
9### The Key Insight
11Given two layers that both require the same dependency:
13```purescript
14layer1 :: OmLayer (config :: Config) (logger :: Logger) ()
15layer2 :: OmLayer (config :: Config) (database :: Database) ()
16```
18When combined using `combineRequirements`, the result has:
20```purescript
21combineRequirements layer1 layer2
22 :: OmLayer (config :: Config) _ _ -- ✓ Single requirement
24NOT :: OmLayer (config :: Config, config :: Config) _ _ -- ✗ Duplicate requirements
25```
27The `Row.Nub` constraint automatically deduplicates `(config, config)` to just `(config)`!
29## Running the Proof
31```bash
32cd packages/yoga-om-layer
33npx spago test
34```
36You'll see two proofs:
38### 1. Type-Level Deduplication Proof
40```
41✓ PROOF OF CONCEPT SUCCESS!
43Key insight: Row.Nub automatically deduplicates at the type level
45The (config, config) requirement is automatically deduplicated
46to just (config) by the Nub constraint in combineRequirements!
47```
49### 2. Runtime Deduplication Proof
51```
52✓ RUNTIME DEDUPLICATION PROOF:
53 Access log: 'config-accessed-by-logger config-accessed-by-database '
55 Both components accessed the same shared context!
56```
58The second test uses a mutable `Ref` that's passed as part of the context. Both the logger and database components write to this Ref when they access the config. The access log shows both messages, proving that:
601. Both components ran
612. They both accessed the same context record (not separate duplicate contexts)
623. The shared dependency (config) was deduplicated at both type-level AND runtime
64## The Type-Level Magic
66The key is in the type signature:
68```purescript
69combineRequirements
70 :: forall req1 req2 reqMerged reqDeduped
71 . Union req1 req2 reqMerged -- Merge: (config, config)
72 => Nub reqMerged reqDeduped -- Dedupe: (config)
73 => OmLayer req1 prov1 err1
74 -> OmLayer req2 prov2 err2
75 -> OmLayer reqDeduped prov3 err3 -- Result has deduplicated reqs!
76```
78## What's Next
80This proof of concept demonstrates the **type-level foundation** for ZLayer-style dependency injection in PureScript. The next steps would be:
821. **Instance resolution for build order** - Use type class instance resolution to determine the correct order to build layers
842. **Error handling** - Better propagation of construction errors
863. **Memoization** - Ensure shared dependencies are built once and cached (currently relies on Om's context being shared)
884. **provideLayers API** - Create a `provideLayers { config, logger, database }` function that uses the type-level magic to wire everything together automatically
90## Custom Error Messages
92The library provides beautiful custom error messages that **show a full diff** of what's required vs what's available!
94```purescript
95needsThree :: OmLayer
96 (config :: Config, logger :: Logger, database :: Database)
97 (cache :: Cache)
98 ()
100-- Provides only logger, missing config and database
101test = runLayer { logger: ... } needsThree
102```
104You'll get a clear, helpful error showing both the specific missing field AND the full context:
106```
107Custom error:
108 Missing required dependency!
110 The first missing field is:
111 "config"
113 The layer requires:
114 ( config :: { port :: Int }
115 , database :: { query :: String -> Effect (Array String) }
116 , logger :: { log :: String -> Effect Unit }
117 )
119 But you provided:
120 ( logger :: { log :: String -> Effect Unit }
121 )
122```
124You get:
125- 🎯 **Actionable**: Fix `config` first
126- 📋 **Complete context**: See everything that's required vs provided
127- 👁️ **Visual diff**: Instantly spot all missing dependencies
129### How It Works
131The implementation uses **RowList-based instance resolution** with `else instance` chains, as described in [this PureScript Discourse thread](https://discourse.purescript.org/t/custom-errors-with-instance-chains/2502/5). The key insight is:
1331. **Dispatch on RowList structure in the instance head** (not in constraints)
1342. **Thread the original Row types through** so we can print them in error messages
136```purescript
137class CheckLabelExists
138 (label :: Symbol)
139 (ty :: Type)
140 (available :: RowList Type)
141 (requiredRow :: Row Type) -- ← Pass through for error message!
142 (availableRow :: Row Type) -- ← Pass through for error message!
144-- Found it! Label matches in instance head
145instance checkLabelExistsFound ::
146 CheckLabelExists label ty (Cons label ty' tail) requiredRow availableRow
148-- Keep looking: different label, recurse
149else instance checkLabelExistsKeepLooking ::
150 CheckLabelExists label ty tail requiredRow availableRow =>
151 CheckLabelExists label ty (Cons otherLabel otherTy tail) requiredRow availableRow
153-- Not found: emit custom error with the SPECIFIC missing label AND full context!
154else instance checkLabelExistsNotFound ::
155 Fail
156 ( Above (Text "The first missing field is: ")
157 (Above (Quote label) -- ← Specific missing field!
158 (Above (Text "The layer requires: ")
159 (Above (Quote requiredRow) -- ← Full required context
160 (Above (Text "But you provided: ")
161 (Quote availableRow) -- ← Full available context
162 )
163 )
164 )
165 )
166 ) =>
167 CheckLabelExists label ty Nil requiredRow availableRow
168```
170By passing both the original `Row Type`s AND the current `label` through the recursion, we can show:
1711. **The specific missing field** (`label`) - tells you exactly what to fix first
1722. **The complete required row** - shows everything the layer needs
1733. **The complete available row** - shows what you actually provided
175This gives you both **actionable** and **contextual** information in one error!
177### Limitations
179**1. Reports the first missing field (but shows complete context)**
181The error technically triggers on the *first* missing label it encounters during row traversal. However, by showing both the complete required and provided rows, you can visually see ALL missing fields at once, even though the compiler stops at the first one.
183This is actually better than showing ONLY the first missing field - you get both specific and contextual information!
185**2. Duplicate provisions are silently deduplicated**
187If two layers provide the same service, `Nub` automatically deduplicates them, and `Record.merge` is right-biased (the second layer wins):
189```purescript
190layer1 :: OmLayer () (logger :: Logger) () -- Provides logger
191layer2 :: OmLayer () (logger :: Logger) () -- Also provides logger
193combined = combineRequirements layer1 layer2
194-- Type: OmLayer () (logger :: Logger) ()
195-- Runtime: layer2's logger is used (right-biased merge)
196```
198No error or warning is emitted. This might be surprising if you accidentally provide the same thing twice.
200### Testing Error Messages
202You can test the custom error messages:
204```bash
205cd packages/yoga-om-layer
206./test-errors.sh
207```
209This automatically compiles negative test cases and verifies the expected error messages appear.
211## Why This Matters
213ZLayer's power comes from automatic dependency deduplication. This proof shows that PureScript's row types can achieve the same thing **without macros** - the compiler's built-in `Row.Nub` constraint does the work!
215This is the foundation for building a full ZLayer-like system in PureScript that leverages:
216- Row polymorphism for extensible dependencies
217- Type-level programming for compile-time validation
218- Instance resolution for automatic ordering
219- Om's existing capabilities for error handling and async
221## Implementation Notes
223`combineRequirements` now uses `Om.expand` from the core Om library to:
2241. Widen the context from each layer's specific requirements to the deduplicated context
2252. Widen error types to empty (for simplicity in this proof of concept)
2263. Actually run both layers and merge their results
228This means the implementation is **fully functional**, not just a type-level trick!
230```purescript
231combineRequirements (OmLayer build1) (OmLayer build2) = makeLayer do
232 -- expand widens context: Om req1 err1 -> Om reqDeduped ()
233 rec1 <- Om.expand build1
234 rec2 <- Om.expand build2
235 -- Merge the results
236 pure (Record.merge rec1 rec2)
237```
239---
241**Status**: Proof of concept complete ✓ with working runtime implementation
242**Next**: Implement full instance-resolution based layer system with provideLayers API