effect-patterns
Effect-TS pattern reference for TMNL. Invoke when implementing services, schemas, atoms, or Effect-based architecture. Provides canonical file locations and pattern precedents.
When & Why to Use This Skill
This Claude skill serves as a comprehensive reference for Effect-TS patterns and architecture, specifically optimized for the TMNL framework. It streamlines the development of scalable TypeScript applications by providing canonical precedents for service implementation, schema definition, and state management. By enforcing the 'Atom-as-State' doctrine and offering clear decision trees for dependency injection, it helps developers maintain architectural consistency, ensure type safety, and avoid common anti-patterns in functional programming.
Use Cases
- Architecting new services by choosing the optimal pattern (Effect.Service vs. Context.Tag) based on requirements for dependency injection or the Strategy Pattern.
- Implementing reactive state management in React applications using the Atom-as-State pattern to eliminate complex stream-to-ref bridging and polling.
- Defining robust domain entities and events using Schema.TaggedStruct and TaggedClass to leverage type-safe pattern matching and domain-driven design.
- Enhancing system observability and debugging by integrating structured error handling with Tagged Errors and tracing with Effect.withSpan.
- Refactoring legacy codebases to follow modern Effect-TS best practices, such as moving away from BANNED anti-patterns like Effect.Ref for React state.
| name | effect-patterns |
|---|---|
| description | Effect-TS pattern reference for TMNL. Invoke when implementing services, schemas, atoms, or Effect-based architecture. Provides canonical file locations and pattern precedents. |
| model_invoked | true |
Effect-TS Patterns for TMNL
CRITICAL DOCTRINE: Atom-as-State
NO EFFECT.REF. EVER.
When React is the consumer via effect-atom, Atom.make() is the primary state mechanism—not Effect.Ref inside services.
- Service methods mutate Atoms directly (
Atom.set) - React subscribes directly to atoms
- This eliminates the Ref→Atom bridge: no polling, no SubscriptionRef, no streams-to-consume-streams
Canonical Sources
Effect-TS Core Documentation
- Submodule:
../../submodules/effect/(from packages/tmnl) - Website docs:
../../submodules/website/(human-authored, battle-tested) - Test patterns:
../../submodules/effect/packages/*/test/*.test.ts
effect-atom Documentation
- Submodule:
../../submodules/effect-atom/ - Test patterns:
../../submodules/effect-atom/packages/atom/test/*.test.ts
TMNL Implementations
- Slider system:
src/lib/slider/(Effect.Service + Atom.runtime) - Data Manager:
src/lib/data-manager/v1/(canonical service pattern) - Layer system:
src/lib/layers/(legacy, being migrated) - Pattern registry:
.edin/EFFECT_PATTERNS.md
Pattern Lookup Protocol
- Check submodules first - Real code beats documentation
- Check TMNL implementations - Battle-tested patterns in context
- Check .edin/EFFECT_PATTERNS.md - Curated registry
- Query deepwiki - Ask "Effect-TS/effect" for verification
Service Definition Patterns (Three Approaches)
Effect-TS provides three primary patterns for defining services. Each has specific use cases.
Decision Tree
Need a service?
│
├─ Multiple swappable implementations (Strategy Pattern)?
│ └─ Use: class extends Context.Tag
│ (e.g., SliderBehavior with 5 curve types)
│
├─ Effectful construction or service dependencies?
│ └─ Use: class extends Effect.Service<>()
│ (Default choice for most services)
│
└─ Simple configuration tag?
└─ Use: class extends Context.Tag
with Static Default + Custom factories
Pattern 1: Effect.Service<>() — RECOMMENDED DEFAULT
When: Default choice for all services. Auto-layers, clean DI, effectful construction.
import * as Effect from 'effect/Effect'
class MyService extends Effect.Service<MyService>()("app/MyService", {
effect: Effect.gen(function* () {
// Yield dependencies
const config = yield* ConfigService;
// Define methods
const doThing = (input: string): Effect.Effect<number> =>
Effect.succeed(input.length);
return { doThing } as const;
}),
dependencies: [ConfigService.Default], // Optional: auto-provides
}) {}
// Auto-generated: MyService.Default layer
// Usage: yield* MyService in Effect.gen
Key Features:
- Double
()()syntax: first parameterizes type, second configures service dependencies: [...]auto-provides required layersas constensures readonly interface
TMNL Examples:
DataManager—src/lib/data-manager/v1/DataManager.ts:73SearchKernel—src/lib/data-manager/v1/kernels/SearchKernel.ts:308IdGenerator—src/lib/layers/v1/services/IdGenerator.ts:34
Pattern 2: class extends Context.Tag — STRATEGY PATTERN
When: Multiple swappable implementations of same interface. Runtime behavior swapping.
import * as Context from 'effect/Context'
import * as Layer from 'effect/Layer'
// Interface shape
interface BehaviorShape {
readonly id: string;
readonly transform: (value: number) => number;
}
// Tag definition
class MyBehavior extends Context.Tag('app/MyBehavior')<
MyBehavior,
BehaviorShape
>() {}
// Multiple implementations
const linearImpl: BehaviorShape = {
id: 'linear',
transform: (v) => v,
};
const logImpl: BehaviorShape = {
id: 'logarithmic',
transform: (v) => Math.log(v),
};
// Layer factories
export const LinearBehavior = {
Default: Layer.succeed(MyBehavior, linearImpl),
shape: linearImpl, // Direct access without Layer
};
export const LogBehavior = {
Default: Layer.succeed(MyBehavior, logImpl),
shape: logImpl,
};
// Usage: swap at runtime via Layer substitution
Effect.provide(LinearBehavior.Default) // or LogBehavior.Default
Key Features:
- Export both
.Default(Layer) AND.shape(direct access) - Perfect for Strategy Pattern
- Runtime swappable via layer substitution
TMNL Examples:
SliderBehavior—src/lib/slider/v1/services/SliderBehavior.ts:15(5 behavior variants)
Pattern 3: Context.Tag with Config — PARAMETERIZED SERVICE
When: Service needs configuration injection. Separate config tag from service.
import * as Context from 'effect/Context'
import * as Layer from 'effect/Layer'
// Config tag FIRST (avoid circular deps)
class MyConfig extends Context.Tag('app/MyConfig')<
MyConfig,
{ strategy: 'fast' | 'secure' }
>() {
static Default = Layer.succeed(this, { strategy: 'fast' });
static Custom = (config: { strategy: 'fast' | 'secure' }) =>
Layer.succeed(this, config);
}
// Service depends on config
class MyService extends Effect.Service<MyService>()("app/MyService", {
effect: Effect.gen(function* () {
const config = yield* MyConfig; // Dependency
const execute = () => config.strategy === 'fast'
? Effect.succeed('fast')
: Effect.sleep('1 second').pipe(Effect.as('secure'));
return { execute } as const;
}),
dependencies: [MyConfig.Default],
}) {}
// Override config at composition site
const customLayer = MyService.Default.pipe(
Layer.provide(MyConfig.Custom({ strategy: 'secure' }))
);
TMNL Examples:
IdGeneratorConfig + IdGenerator—src/lib/layers/v1/services/IdGenerator.ts
Pattern Comparison Table
| Feature | Effect.Service<>() |
class extends Context.Tag |
|---|---|---|
| Auto-generates Layer | ✅ Yes (.Default) |
❌ Manual Layer.succeed |
| Effectful construction | ✅ Effect.gen |
⚠️ Needs Layer.effect |
| Dependencies array | ✅ dependencies: [...] |
⚠️ Manual Layer.provide |
| Multiple implementations | ⚠️ Possible but awkward | ✅ Idiomatic |
| Recommended for new code | ✅ Default choice | ⚠️ Strategy Pattern only |
Common Gotchas
1. Double ()() Syntax
// WRONG
class MyService extends Effect.Service<MyService>("id", { ... }) {}
// CORRECT
class MyService extends Effect.Service<MyService>()("id", { ... }) {}
2. Config Tag BEFORE Service
// WRONG — Circular dependency!
class MyService extends Effect.Service<MyService>()("id", {
effect: Effect.gen(function* () {
const config = yield* MyConfig; // MyConfig not defined yet!
}),
}) {}
class MyConfig extends Context.Tag("config")<...>() {}
// CORRECT — Config first
class MyConfig extends Context.Tag("config")<...>() {}
class MyService extends Effect.Service<MyService>()("id", { ... }) {}
3. Always use as const
// WRONG
return { doThing };
// CORRECT
return { doThing } as const;
Atom-as-State (THE State Pattern)
When React consumes Effect services, Atoms ARE the state.
import { Atom } from '@effect-rx/rx-react'
import { Effect, Layer } from 'effect'
// State lives in Atoms, NOT Refs
const resultsAtom = Atom.make<SearchResult[]>([])
const statusAtom = Atom.make<'idle' | 'loading' | 'complete'>('idle')
// Service methods mutate Atoms directly
interface SearchService {
readonly search: (query: string) => Effect.Effect<void>
}
const searchServiceImpl: SearchService = {
search: (query) => Effect.gen(function* () {
Atom.set(statusAtom, 'loading')
Atom.set(resultsAtom, [])
const results = yield* performSearch(query)
Atom.set(resultsAtom, results)
Atom.set(statusAtom, 'complete')
})
}
// React subscribes directly
function SearchResults() {
const results = useAtomValue(resultsAtom)
const status = useAtomValue(statusAtom)
return <Grid data={results} loading={status === 'loading'} />
}
Canonical source: src/lib/data-manager/v1/DataManager.ts
Pattern 3: Atom.runtime for Service Composition
Combine service layers with reactive atoms.
import { Atom } from '@effect-rx/rx-react'
import { Layer } from 'effect'
// Create runtime from composed layers
export const dataManagerRuntime = Atom.runtime(
Layer.mergeAll(
SearchKernel.Live,
StreamService.Live,
DataManager.Live
)
)
// Create operation atoms
export const searchAtom = dataManagerRuntime.fn(
(query: string) => Effect.gen(function* () {
const dm = yield* DataManager
yield* dm.search(query)
})
)
// React usage
function SearchBox() {
const doSearch = useAtomCallback(searchAtom)
return <input onChange={e => doSearch(e.target.value)} />
}
Canonical source: src/lib/slider/atoms/index.ts
Pattern 4: Schema.TaggedStruct for Events
All domain events use discriminated unions with _tag.
import { Schema } from 'effect'
const SearchStarted = Schema.TaggedStruct('SearchStarted', {
query: Schema.String,
timestamp: Schema.DateFromSelf,
})
const SearchCompleted = Schema.TaggedStruct('SearchCompleted', {
query: Schema.String,
resultCount: Schema.Number,
durationMs: Schema.Number,
})
const SearchEvent = Schema.Union(SearchStarted, SearchCompleted)
type SearchEvent = typeof SearchEvent.Type
// Pattern match on _tag
function handle(event: SearchEvent) {
switch (event._tag) {
case 'SearchStarted': return startSpinner()
case 'SearchCompleted': return showResults(event.resultCount)
}
}
Canonical source: src/lib/data-manager/v1/types.ts
Pattern 5: Schema.TaggedClass for Entities
Entities with methods use TaggedClass.
import { Schema } from 'effect'
class GridColumn extends Schema.TaggedClass<GridColumn>()('GridColumn', {
field: Schema.String,
headerName: Schema.String,
width: Schema.optional(Schema.Number),
sortable: Schema.optional(Schema.Boolean),
}) {
get displayWidth() {
return this.width ?? 150
}
withWidth(width: number) {
return new GridColumn({ ...this, width })
}
}
Canonical source: src/lib/data-grid/types.ts
Pattern 6: Effect.withSpan for Observability
Traced operations for DevTools visibility.
const search = (query: string) =>
Effect.gen(function* () {
yield* Effect.log(`Searching: ${query}`)
const results = yield* searchKernel.query(query)
return results
}).pipe(
Effect.withSpan('DataManager.search', {
attributes: { query, timestamp: Date.now() }
})
)
Canonical source: src/lib/data-manager/v1/DataManager.ts
Pattern 7: Layer Composition
Compose layers for dependency injection.
// Individual service layers
const IdGeneratorLive = Layer.succeed(IdGenerator, nanoidGenerator)
const FactoryLive = Layer.effect(Factory, makeFactory).pipe(
Layer.provide(IdGeneratorLive)
)
const ManagerLive = Layer.effect(Manager, makeManager).pipe(
Layer.provide(FactoryLive)
)
// Compose into single runtime layer
const AppLayer = Layer.mergeAll(
IdGeneratorLive,
FactoryLive,
ManagerLive
)
// Use with Atom.runtime
const appRuntime = Atom.runtime(AppLayer)
Canonical source: src/lib/layers/index.ts
Pattern 8: Error Handling with Tagged Errors
Domain errors as tagged structs.
import { Schema, Data } from 'effect'
class SearchError extends Data.TaggedError('SearchError')<{
readonly query: string
readonly cause: unknown
}> {}
class IndexNotReadyError extends Data.TaggedError('IndexNotReadyError')<{
readonly kernel: string
}> {}
// Usage
const search = (query: string) =>
Effect.gen(function* () {
if (!indexed) {
yield* Effect.fail(new IndexNotReadyError({ kernel: 'flex' }))
}
// ...
})
// Pattern match errors
Effect.catchTags({
SearchError: (e) => Effect.log(`Search failed: ${e.query}`),
IndexNotReadyError: (e) => Effect.log(`Index ${e.kernel} not ready`),
})
Filing New Patterns
When you discover or create a new Effect pattern:
- Implement in TMNL first - Working code in
src/lib/ - Add to registry - Update
.edin/EFFECT_PATTERNS.md - Update this skill - Add pattern with canonical source
- Create bead - Track with
bd create --type=task --title="Document X pattern"
Anti-Patterns (BANNED)
Effect.Ref for React State
// BANNED - Do not do this
const stateRef = yield* Ref.make<State>(initial)
// ...poll ref, bridge to atom, complexity explosion
Streams-to-Consume-Streams
// BANNED - SubscriptionRef → Stream → consume → update atom
// Just use Atom.set directly in service methods
useState for Cross-Component State
// BANNED when state crosses boundaries
const [results, setResults] = useState([])
const [status, setStatus] = useState('idle')
// Use Atom.make + service methods instead
Related Ecosystem
- Agent:
.claude/agents/effect-specialist.md(TODO) - Command:
.claude/commands/effect.md(TODO) - Hook:
.claude/hooks/effect-patterns.json(TODO)