Type-level dependency deduplication for PureScript's Om monad
This 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.
Given two layers that both require the same dependency:
layer1 :: OmLayer (config :: Config) (logger :: Logger) ()
layer2 :: OmLayer (config :: Config) (database :: Database) ()When combined using combineRequirements, the result has:
combineRequirements layer1 layer2
:: OmLayer (config :: Config) _ _ -- ✓ Single requirement
NOT :: OmLayer (config :: Config, config :: Config) _ _ -- ✗ Duplicate requirementsThe Row.Nub constraint automatically deduplicates (config, config) to just (config)!
cd packages/yoga-om-layer
npx spago testYou'll see two proofs:
✓ PROOF OF CONCEPT SUCCESS!
Key insight: Row.Nub automatically deduplicates at the type level
The (config, config) requirement is automatically deduplicated
to just (config) by the Nub constraint in combineRequirements!
✓ RUNTIME DEDUPLICATION PROOF:
Access log: 'config-accessed-by-logger config-accessed-by-database '
Both components accessed the same shared context!
The 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:
- Both components ran
- They both accessed the same context record (not separate duplicate contexts)
- The shared dependency (config) was deduplicated at both type-level AND runtime
The key is in the type signature:
combineRequirements
:: forall req1 req2 reqMerged reqDeduped
. Union req1 req2 reqMerged -- Merge: (config, config)
=> Nub reqMerged reqDeduped -- Dedupe: (config)
=> OmLayer req1 prov1 err1
-> OmLayer req2 prov2 err2
-> OmLayer reqDeduped prov3 err3 -- Result has deduplicated reqs!This proof of concept demonstrates the type-level foundation for ZLayer-style dependency injection in PureScript. The next steps would be:
-
Instance resolution for build order - Use type class instance resolution to determine the correct order to build layers
-
Error handling - Better propagation of construction errors
-
Memoization - Ensure shared dependencies are built once and cached (currently relies on Om's context being shared)
-
provideLayers API - Create a
provideLayers { config, logger, database }function that uses the type-level magic to wire everything together automatically
The library provides beautiful custom error messages that show a full diff of what's required vs what's available!
needsThree :: OmLayer
(config :: Config, logger :: Logger, database :: Database)
(cache :: Cache)
()
-- Provides only logger, missing config and database
test = runLayer { logger: ... } needsThreeYou'll get a clear, helpful error showing both the specific missing field AND the full context:
Custom error:
Missing required dependency!
The first missing field is:
"config"
The layer requires:
( config :: { port :: Int }
, database :: { query :: String -> Effect (Array String) }
, logger :: { log :: String -> Effect Unit }
)
But you provided:
( logger :: { log :: String -> Effect Unit }
)
You get:
- 🎯 Actionable: Fix
configfirst - 📋 Complete context: See everything that's required vs provided
- 👁️ Visual diff: Instantly spot all missing dependencies
The implementation uses RowList-based instance resolution with else instance chains, as described in this PureScript Discourse thread. The key insight is:
- Dispatch on RowList structure in the instance head (not in constraints)
- Thread the original Row types through so we can print them in error messages
class CheckLabelExists
(label :: Symbol)
(ty :: Type)
(available :: RowList Type)
(requiredRow :: Row Type) -- ← Pass through for error message!
(availableRow :: Row Type) -- ← Pass through for error message!
-- Found it! Label matches in instance head
instance checkLabelExistsFound ::
CheckLabelExists label ty (Cons label ty' tail) requiredRow availableRow
-- Keep looking: different label, recurse
else instance checkLabelExistsKeepLooking ::
CheckLabelExists label ty tail requiredRow availableRow =>
CheckLabelExists label ty (Cons otherLabel otherTy tail) requiredRow availableRow
-- Not found: emit custom error with the SPECIFIC missing label AND full context!
else instance checkLabelExistsNotFound ::
Fail
( Above (Text "The first missing field is: ")
(Above (Quote label) -- ← Specific missing field!
(Above (Text "The layer requires: ")
(Above (Quote requiredRow) -- ← Full required context
(Above (Text "But you provided: ")
(Quote availableRow) -- ← Full available context
)
)
)
)
) =>
CheckLabelExists label ty Nil requiredRow availableRowBy passing both the original Row Types AND the current label through the recursion, we can show:
- The specific missing field (
label) - tells you exactly what to fix first - The complete required row - shows everything the layer needs
- The complete available row - shows what you actually provided
This gives you both actionable and contextual information in one error!
1. Reports the first missing field (but shows complete context)
The 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.
This is actually better than showing ONLY the first missing field - you get both specific and contextual information!
2. Duplicate provisions are silently deduplicated
If two layers provide the same service, Nub automatically deduplicates them, and Record.merge is right-biased (the second layer wins):
layer1 :: OmLayer () (logger :: Logger) () -- Provides logger
layer2 :: OmLayer () (logger :: Logger) () -- Also provides logger
combined = combineRequirements layer1 layer2
-- Type: OmLayer () (logger :: Logger) ()
-- Runtime: layer2's logger is used (right-biased merge)No error or warning is emitted. This might be surprising if you accidentally provide the same thing twice.
You can test the custom error messages:
cd packages/yoga-om-layer
./test-errors.shThis automatically compiles negative test cases and verifies the expected error messages appear.
ZLayer'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!
This is the foundation for building a full ZLayer-like system in PureScript that leverages:
- Row polymorphism for extensible dependencies
- Type-level programming for compile-time validation
- Instance resolution for automatic ordering
- Om's existing capabilities for error handling and async
combineRequirements now uses Om.expand from the core Om library to:
- Widen the context from each layer's specific requirements to the deduplicated context
- Widen error types to empty (for simplicity in this proof of concept)
- Actually run both layers and merge their results
This means the implementation is fully functional, not just a type-level trick!
combineRequirements (OmLayer build1) (OmLayer build2) = makeLayer do
-- expand widens context: Om req1 err1 -> Om reqDeduped ()
rec1 <- Om.expand build1
rec2 <- Om.expand build2
-- Merge the results
pure (Record.merge rec1 rec2)Status: Proof of concept complete ✓ with working runtime implementation Next: Implement full instance-resolution based layer system with provideLayers API