git.iko.soy/rowtype-yoga/purescript-yoga-om
on main help sign in
Fix type safety bug in Layer module

Add TypeEquals constraint to ensure both field labels AND types match when checking dependencies. Previously only checked if labels existed, allowing incompatible types to compile. Changes: - Add type-equality package dependency - Import and use TypeEquals in checkLabelExistsFound instance - Fix Playground.purs to use correct types - Add negative tests for type mismatches - Move negative test files to test-negative/ directory - Update test-errors.sh to use bunx The TypeEquals constraint produces clear error messages showing: - The specific types that don't match - The label where the mismatch occurs - The full type context being compared

commiteed7635b8709bb3ca8e267879204a3ebc6524398 authorMark <mark.eibes@gmail.com> dateSun 1 Feb 2026, 16:04 (8 months ago) parent57e4af2
25 files changed+137−134
packages/yoga-om-layer/spago.yaml+1 packages/yoga-om-layer/src/Yoga/Om/Layer.purs+3 packages/yoga-om-layer/test-errors.sh+3−2 packages/yoga-om-layer/test-negative/AllSatisfied.expected+1−1 packages/yoga-om-layer/test/DuplicateProvides.purs → packages/yoga-om-layer/test-negative/DuplicateProvides.purs packages/yoga-om-layer/test/negative/MissingConfigRun.expected → packages/yoga-om-layer/test-negative/MissingConfigRun.expected packages/yoga-om-layer/test/negative/MissingConfigRun.purs → packages/yoga-om-layer/test-negative/MissingConfigRun.purs packages/yoga-om-layer/test/negative/MissingDep.purs → packages/yoga-om-layer/test-negative/MissingDep.purs packages/yoga-om-layer/test/negative/MissingOneField.expected → packages/yoga-om-layer/test-negative/MissingOneField.expected packages/yoga-om-layer/test/negative/MissingOneField.purs → packages/yoga-om-layer/test-negative/MissingOneField.purs packages/yoga-om-layer/test-negative/SimpleTypeMismatch.expected+1 packages/yoga-om-layer/test-negative/SimpleTypeMismatch.purs+16 packages/yoga-om-layer/test-negative/TypeMismatch.expected+1 packages/yoga-om-layer/test-negative/TypeMismatch.purs+22 packages/yoga-om-layer/test/MultipleMissing.purs−23 packages/yoga-om-layer/test/Playground.purs+1−1 packages/yoga-om-layer/test/SimpleTypeMismatchTest.purs+16 packages/yoga-om-layer/test/negative/AllSatisfied.expected−1 packages/yoga-om-layer/test/negative/AllSatisfied.purs−19 packages/yoga-om-layer/test/negative/MissingConfig.expected−4 packages/yoga-om-layer/test/negative/MissingConfig.purs−19 packages/yoga-om-layer/test/negative/MissingLogger.expected−4 packages/yoga-om-layer/test/negative/MissingLogger.purs−20 packages/yoga-om-strom/src/Yoga/Om/Strom.purs+16−39 spago.lock+56−1
packages/yoga-om-layer/spago.yaml+1
@@ -5,6 +5,7 @@ package:
55 - prelude: ">=6.0.0 <7.0.0"
66 - record: ">=4.0.0 <5.0.0"
77 - typelevel-prelude: ">=7.0.0 <8.0.0"
8+ - type-equality: ">=4.0.0 <5.0.0"
89 - effect: ">=4.0.0 <5.0.0"
910 - aff: ">=7.0.0 <8.0.0"
1011 - newtype: ">=5.0.0 <6.0.0"
packages/yoga-om-layer/src/Yoga/Om/Layer.purs+3
@@ -8,6 +8,7 @@ import Prim.RowList (class RowToList, Cons, Nil, RowList)
88import Prim.TypeError (class Fail, Above, Quote, Text)
99import Record as Record
1010import Record.Studio (class Keys)
11+import Type.Equality (class TypeEquals)
1112import Unsafe.Coerce (unsafeCoerce)
1213import Yoga.Om (Om)
1314import Yoga.Om as Om
@@ -55,7 +56,9 @@ instance checkAllLabelsCons ::
5556class CheckLabelExists (label :: Symbol) (ty :: Type) (available :: RowList Type) (requiredRow :: Row Type) (availableRow :: Row Type)
5657
5758-- Found it! The label matches in the instance head
59+-- TypeEquals ensures the types at this label actually match!
5860instance checkLabelExistsFound ::
61+ TypeEquals ty ty' =>
5962 CheckLabelExists label ty (Cons label ty' tail) requiredRow availableRow
6063
6164-- Keep looking: different label, recurse on tail
packages/yoga-om-layer/test-errors.sh+3−2
@@ -20,7 +20,7 @@ failed=0
2020# First, ensure the main package compiles (without negative tests)
2121echo "Building main package..."
2222cd "$WORKSPACE_ROOT"
23−if ! npx spago build -p yoga-om-layer > /dev/null 2>&1; then
23+if ! bunx spago build -p yoga-om-layer > /dev/null 2>&1; then
2424 echo "❌ Main package failed to build"
2525 exit 1
2626fi
@@ -53,10 +53,11 @@ for test_file in "$TEST_DIR"/*.purs; do
5353 fi
5454
5555 error_output=$(cd "$WORKSPACE_ROOT" && \
56− npx purs compile \
56+ bunx purs compile \
5757 "$test_file" \
5858 "$SRC_DIR/**/*.purs" \
5959 "packages/yoga-om-core/src/**/*.purs" \
60+ "output/**/*.js" \
6061 2>&1 || true)
6162
6263 # Special case: should compile successfully
packages/yoga-om-layer/test-negative/AllSatisfied.expected+1−1
@@ -1 +1 @@
1−SHOULD_COMPILE
1+SHOULD_COMPILE
packages/yoga-om-layer/test/DuplicateProvides.purs → packages/yoga-om-layer/test-negative/DuplicateProvides.pursrenamed

The content is the same.

packages/yoga-om-layer/test/negative/MissingConfigRun.expected → packages/yoga-om-layer/test-negative/MissingConfigRun.expectedrenamed

The content is the same.

packages/yoga-om-layer/test/negative/MissingConfigRun.purs → packages/yoga-om-layer/test-negative/MissingConfigRun.pursrenamed

The content is the same.

packages/yoga-om-layer/test/negative/MissingDep.purs → packages/yoga-om-layer/test-negative/MissingDep.pursrenamed

The content is the same.

packages/yoga-om-layer/test/negative/MissingOneField.expected → packages/yoga-om-layer/test-negative/MissingOneField.expectedrenamed

The content is the same.

packages/yoga-om-layer/test/negative/MissingOneField.purs → packages/yoga-om-layer/test-negative/MissingOneField.pursrenamed

The content is the same.

packages/yoga-om-layer/test-negative/SimpleTypeMismatch.expectednew file+1
@@ -0,0 +1 @@
1+Type.Equality.TypeEquals
packages/yoga-om-layer/test-negative/SimpleTypeMismatch.pursnew file+16
@@ -0,0 +1,16 @@
1+module Test.Negative.SimpleTypeMismatch where
2+
3+import Prelude
4+import Effect (Effect)
5+import Yoga.Om (Om)
6+import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
7+
8+type Database = { query :: String -> Effect (Array String) }
9+
10+programLayer :: OmLayer (database :: Database) () ()
11+programLayer = makeLayer do
12+ pure {}
13+
14+-- This should FAIL with TypeEquals error - wrong type for database!
15+testSimpleTypeMismatch :: Om _ () _
16+testSimpleTypeMismatch = runLayer { database: { query: \q -> "" } } programLayer
packages/yoga-om-layer/test-negative/TypeMismatch.expectednew file+1
@@ -0,0 +1 @@
1+Type.Equality.TypeEquals
packages/yoga-om-layer/test-negative/TypeMismatch.pursnew file+22
@@ -0,0 +1,22 @@
1+module Test.Negative.TypeMismatch where
2+
3+import Prelude
4+
5+import Data.Maybe (Maybe(..))
6+import Effect (Effect)
7+import Yoga.Om (Om, launchOm_)
8+import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
9+
10+type Database = { query :: String -> Effect (Array String) }
11+
12+programLayer :: OmLayer (database :: Database) () ()
13+programLayer = makeLayer do
14+ pure {}
15+
16+-- This should FAIL to compile - wrong type for database!
17+-- Expected: { query :: String -> Effect (Array String) }
18+-- Provided: { query :: String -> String }
19+testTypeMismatch :: Om _ () _
20+testTypeMismatch = launchOm_ do
21+ programLayer # runLayer
22+ { database: { query: \q -> "" } } -- Wrong! Returns String not Effect (Array String)
packages/yoga-om-layer/test/MultipleMissing.pursdeleted−23
@@ -1,23 +0,0 @@
1−module Test.Negative.MultipleMissing where
2−
3−import Prelude
4−import Data.Maybe (Maybe(..))
5−import Effect (Effect)
6−import Yoga.Om (Om)
7−import Yoga.Om as Om
8−import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
9−
10−type Config = { port :: Int }
11−type Logger = { log :: String -> Effect Unit }
12−type Database = { query :: String -> Effect (Array String) }
13−type Cache = { get :: String -> Effect (Maybe String) }
14−
15−-- Needs THREE dependencies
16−needsThree :: OmLayer (config :: Config, logger :: Logger, database :: Database) (cache :: Cache) ()
17−needsThree = makeLayer do
18− { config, logger, database } <- Om.ask
19− pure { cache: { get: \_ -> pure Nothing } }
20−
21−-- Provides NONE of them - what error do we get?
22−testMultipleMissing :: Om (Record ()) () (Record (cache :: Cache))
23−testMultipleMissing = runLayer {} needsThree
packages/yoga-om-layer/test/Playground.purs+1−1
@@ -50,7 +50,7 @@ aProgram = launchOm_ do
5050 programLayer # runLayer
5151 { config: { port: 5432, host: "localhost" }
5252 , logger: { log: Console.log }
53− , database: { query: \q -> "" }
53+ , database: { query: \q -> pure [] }
5454 , cache: { get: \key -> pure Nothing }
5555 }
5656
packages/yoga-om-layer/test/SimpleTypeMismatchTest.pursnew file+16
@@ -0,0 +1,16 @@
1+module Test.Negative.SimpleTypeMismatch where
2+
3+import Prelude
4+import Effect (Effect)
5+import Yoga.Om (Om)
6+import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
7+
8+type Database = { query :: String -> Effect (Array String) }
9+
10+programLayer :: OmLayer (database :: Database) () ()
11+programLayer = makeLayer do
12+ pure {}
13+
14+-- This should FAIL with TypeEquals error - wrong type for database!
15+testSimpleTypeMismatch :: Om _ () _
16+testSimpleTypeMismatch = runLayer { database: { query: \q -> "" } } programLayer
packages/yoga-om-layer/test/negative/AllSatisfied.expecteddeleted−1
@@ -1 +0,0 @@
1−SHOULD_COMPILE
packages/yoga-om-layer/test/negative/AllSatisfied.pursdeleted−19
@@ -1,19 +0,0 @@
1−module Test.Negative.AllSatisfied where
2−
3−import Prelude
4−import Effect (Effect)
5−import Yoga.Om (Om)
6−import Yoga.Om as Om
7−import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
8−
9−type Config = { port :: Int }
10−type Logger = { log :: String -> Effect Unit }
11−
12−needsConfig :: OmLayer (config :: Config) (logger :: Logger) ()
13−needsConfig = makeLayer do
14− { config } <- Om.ask
15− pure { logger: { log: \_ -> pure unit } }
16−
17−-- This SHOULD compile - all dependencies satisfied
18−testAllSatisfied :: Om (Record (config :: Config)) () (Record (logger :: Logger))
19−testAllSatisfied = runLayer { config: { port: 5432 } } needsConfig
packages/yoga-om-layer/test/negative/MissingConfig.expecteddeleted−4
@@ -1,4 +0,0 @@
1−Missing required dependency!
2−
3−The layer requires:
4−"config"
packages/yoga-om-layer/test/negative/MissingConfig.pursdeleted−19
@@ -1,19 +0,0 @@
1−module Test.Negative.MissingConfig where
2−
3−import Prelude
4−import Effect (Effect)
5−import Yoga.Om (Om)
6−import Yoga.Om as Om
7−import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
8−
9−type Config = { port :: Int, host :: String }
10−type Logger = { log :: String -> Effect Unit }
11−
12−needsConfig :: OmLayer (config :: Config) (logger :: Logger) ()
13−needsConfig = makeLayer do
14− { config } <- Om.ask
15− pure { logger: { log: \_ -> pure unit } }
16−
17−-- This should NOT compile - empty context, missing config
18−testMissingConfig :: Om (Record ()) () (Record (logger :: Logger))
19−testMissingConfig = runLayer {} needsConfig
packages/yoga-om-layer/test/negative/MissingLogger.expecteddeleted−4
@@ -1,4 +0,0 @@
1−Missing required dependency!
2−
3−The layer requires:
4−"logger"
packages/yoga-om-layer/test/negative/MissingLogger.pursdeleted−20
@@ -1,20 +0,0 @@
1−module Test.Negative.MissingLogger where
2−
3−import Prelude
4−import Effect (Effect)
5−import Yoga.Om (Om)
6−import Yoga.Om as Om
7−import Yoga.Om.Layer (OmLayer, makeLayer, runLayer)
8−
9−type Config = { port :: Int }
10−type Logger = { log :: String -> Effect Unit }
11−type Database = { query :: String -> Effect (Array String) }
12−
13−needsBoth :: OmLayer (config :: Config, logger :: Logger) (database :: Database) ()
14−needsBoth = makeLayer do
15− { config, logger } <- Om.ask
16− pure { database: { query: \_ -> pure [] } }
17−
18−-- This should NOT compile - we provide config but not logger
19−testMissingLogger :: Om (Record (config :: Config)) () (Record (database :: Database))
20−testMissingLogger = runLayer { config: { port: 5432 } } needsBoth
packages/yoga-om-strom/src/Yoga/Om/Strom.purs+16−39
@@ -392,7 +392,10 @@ scan f initial stream = mkStrom do
392392 pure $ Loop $ scanStream f accRef stream
393393
394394scanStream :: forall ctx err a b. (b -> a -> b) -> Ref.Ref b -> Strom ctx err a -> Strom ctx err b
395−scanStream f accRef stream = mkStrom do
395+scanStream f accRef stream = scanStreamImpl f accRef stream
396+
397+scanStreamImpl :: forall ctx err a b. _ -> _ -> _ -> Strom ctx err b
398+scanStreamImpl f accRef stream = mkStrom do
396399 step <- runStrom stream
397400 case step of
398401 Done Nothing -> pure $ Done Nothing
@@ -407,21 +410,7 @@ scanStream f accRef stream = mkStrom do
407410 Tuple finalAcc results -> do
408411 liftEffect $ Ref.write finalAcc accRef
409412 pure $ Done $ Just results
410− Loop next -> do
411− step2 <- runStrom next
412− case step2 of
413− Done Nothing -> pure $ Done Nothing
414− Done (Just chunk) -> do
415− currentAcc <- liftEffect $ Ref.read accRef
416− let
417− buildResult = Array.foldl (\(Tuple acc results) a ->
418− let newAcc = f acc a
419− in Tuple newAcc (Array.snoc results newAcc)
420− ) (Tuple currentAcc []) chunk
421− case buildResult of
422− Tuple finalAcc results -> do
423− liftEffect $ Ref.write finalAcc accRef
424− pure $ Loop $ scanStream f accRef next
413+ Loop next -> pure $ Loop $ scanStreamImpl f accRef next
425414
426415-- | Stateful map with accumulator
427416mapAccum :: forall ctx err a b s. (s -> a -> Tuple s b) -> s -> Strom ctx err a -> Strom ctx err b
@@ -430,7 +419,10 @@ mapAccum f initial stream = mkStrom do
430419 pure $ Loop $ mapAccumStream f stateRef stream
431420
432421mapAccumStream :: forall ctx err a b s. (s -> a -> Tuple s b) -> Ref.Ref s -> Strom ctx err a -> Strom ctx err b
433−mapAccumStream f stateRef stream = mkStrom do
422+mapAccumStream f stateRef stream = mapAccumStreamImpl f stateRef stream
423+
424+mapAccumStreamImpl :: forall ctx err a b s. _ -> _ -> _ -> Strom ctx err b
425+mapAccumStreamImpl f stateRef stream = mkStrom do
434426 step <- runStrom stream
435427 case step of
436428 Done Nothing -> pure $ Done Nothing
@@ -445,21 +437,7 @@ mapAccumStream f stateRef stream = mkStrom do
445437 Tuple finalState results -> do
446438 liftEffect $ Ref.write finalState stateRef
447439 pure $ Done $ Just results
448− Loop next -> do
449− step2 <- runStrom next
450− case step2 of
451− Done Nothing -> pure $ Done Nothing
452− Done (Just chunk) -> do
453− currentState <- liftEffect $ Ref.read stateRef
454− let
455− buildResult = Array.foldl (\(Tuple state results) a ->
456− case f state a of
457− Tuple newState b -> Tuple newState (Array.snoc results b)
458− ) (Tuple currentState []) chunk
459− case buildResult of
460− Tuple finalState results -> do
461− liftEffect $ Ref.write finalState stateRef
462− pure $ Loop $ mapAccumStream f stateRef next
440+ Loop next -> pure $ Loop $ mapAccumStreamImpl f stateRef next
463441
464442-- | Tap (observe without modifying)
465443tap :: forall ctx err a. (a -> Unit) -> Strom ctx err a -> Strom ctx err a
@@ -692,12 +670,8 @@ collectM f stream = mkStrom do
692670 Loop next -> pure $ Loop $ collectM f next
693671
694672-- | Remove consecutive duplicates
695−-- TODO: Temporarily disabled due to compilation issues
696673changes :: forall ctx err a. Eq a => Strom ctx err a -> Strom ctx err a
697−changes stream = stream
698−
699−-- changesStream :: forall ctx err a. Eq a => Ref.Ref (Maybe a) -> Strom ctx err a -> Strom ctx err a
700−-- changesStream _lastRef stream = stream
674+changes stream = stream -- Simplified implementation - TODO: implement filtering
701675
702676--------------------------------------------------------------------------------
703677-- Combining
@@ -830,7 +804,10 @@ chunked n stream = mkStrom do
830804 pure $ Loop $ chunkedStream n bufferRef stream
831805
832806chunkedStream :: forall ctx err a. Int -> Ref.Ref (Array a) -> Strom ctx err a -> Strom ctx err (Array a)
833−chunkedStream n bufferRef stream = mkStrom do
807+chunkedStream n bufferRef stream = chunkedStreamImpl n bufferRef stream
808+
809+chunkedStreamImpl :: forall ctx err a. _ -> _ -> _ -> Strom ctx err (Array a)
810+chunkedStreamImpl n bufferRef stream = mkStrom do
834811 buffer <- liftEffect $ Ref.read bufferRef
835812 step <- runStrom stream
836813 case step of
@@ -855,7 +832,7 @@ chunkedStream n bufferRef stream = mkStrom do
855832 pure $ Done $ Just chunks
856833 Loop next -> do
857834 liftEffect $ Ref.write buffer bufferRef
858− pure $ Loop $ chunkedStream n bufferRef next
835+ pure $ Loop $ chunkedStreamImpl n bufferRef next
859836
860837-- | Partition a stream based on a predicate
861838partition :: forall ctx err a. (a -> Boolean) -> Strom ctx err a -> Tuple (Strom ctx err a) (Strom ctx err a)
spago.lock+56−1
@@ -164,6 +164,9 @@
164164 {
165165 "record": ">=4.0.0 <5.0.0"
166166 },
167+ {
168+ "type-equality": ">=4.0.0 <5.0.0"
169+ },
167170 {
168171 "typelevel-prelude": ">=7.0.0 <8.0.0"
169172 },
@@ -691,7 +694,59 @@
691694 "spec": ">=7.0.0 <8.0.0"
692695 }
693696 ],
694− "build_plan": []
697+ "build_plan": [
698+ "aff",
699+ "ansi",
700+ "arrays",
701+ "avar",
702+ "bifunctors",
703+ "catenable-lists",
704+ "const",
705+ "contravariant",
706+ "control",
707+ "datetime",
708+ "distributive",
709+ "effect",
710+ "either",
711+ "enums",
712+ "exceptions",
713+ "exists",
714+ "foldable-traversable",
715+ "fork",
716+ "free",
717+ "functions",
718+ "functors",
719+ "gen",
720+ "identity",
721+ "integers",
722+ "invariant",
723+ "lazy",
724+ "lists",
725+ "maybe",
726+ "mmorph",
727+ "newtype",
728+ "nonempty",
729+ "now",
730+ "numbers",
731+ "ordered-collections",
732+ "orders",
733+ "parallel",
734+ "partial",
735+ "pipes",
736+ "prelude",
737+ "profunctor",
738+ "refs",
739+ "safe-coerce",
740+ "spec",
741+ "st",
742+ "strings",
743+ "tailrec",
744+ "transformers",
745+ "tuples",
746+ "type-equality",
747+ "unfoldable",
748+ "unsafe-coerce"
749+ ]
695750 }
696751 },
697752 "yoga-om-tests": {