better-effect 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,618 +1,299 @@
1
1
  # better-effect
2
2
 
3
- Lightweight Effect-inspired primitives built around [`better-result`](https://www.npmjs.com/package/better-result).
3
+ **Effect-like dependency safety for better-result.**
4
4
 
5
- `better-effect` focuses on a small set of ideas that are useful in application code without bringing in a full effect runtime:
5
+ Type your errors with `better-result`. Typecheck the rest of your application wiring with `better-effect`.
6
6
 
7
- - **Service**contextual dependency access with `yield*`
8
- - **Layer** — declarative composition of live/test environments
9
- - **Scope** — contextual lifetime and finalizer management
10
- - **Resource** — standalone Result-oriented acquire/use/release helper
11
- - **DI adapters** — dependency resolution is delegated to an external container instead of being reimplemented by the library
12
-
13
- The goal is not to recreate Effect. The goal is to provide a small, composable layer on top of `better-result`.
14
-
15
- ## Installation
7
+ Use Services directly inside `Effect.gen`, compose implementations into application environments, and let TypeScript catch missing dependencies before your application starts while keeping Promises, `better-result`, and your DI backend.
16
8
 
17
9
  ```bash
18
10
  bun add better-effect better-result
19
11
  ```
20
12
 
21
- If you want the ITI adapter:
22
-
23
- ```bash
24
- bun add iti
25
- ```
26
-
27
- ## Service
28
-
29
- A service is a class that also acts as its own dependency token.
13
+ ## TypeScript knows what your application needs
30
14
 
31
15
  ```ts
32
16
  import { Result } from 'better-result'
33
- import { Effect, Service } from 'better-effect'
17
+ import { Effect, Layer, Runtime, Service } from 'better-effect'
34
18
 
35
- export class Database extends Service<Database>() {
36
- findUser(email: string) {
37
- return Promise.resolve({
38
- id: '1',
39
- email
40
- })
19
+ class Database extends Service<Database>()('Database') {
20
+ findUser(id: string) {
21
+ // ...
41
22
  }
42
23
  }
43
24
 
44
- export class UserRepository extends Service<UserRepository>() {
45
- findByEmail(email: string) {
25
+ class UserRepository extends Service<UserRepository>()('UserRepository') {
26
+ findUser(id: string) {
46
27
  return Effect.gen(async function* () {
47
28
  const database = yield* Database
48
29
 
49
- const user = await database.findUser(email)
50
-
51
- return Result.ok(user)
30
+ return Result.ok(await database.findUser(id))
52
31
  })
53
32
  }
54
33
  }
55
- ```
56
34
 
57
- Use `Effect.gen` when a generator accesses Services. It delegates execution to
58
- `better-result` while carrying the required Service tokens in a type-only
59
- metadata channel:
35
+ const UserRepositoryLive = Layer.make(UserRepository)
60
36
 
61
- ```ts
62
- export class AuthService extends Service<AuthService>() {
63
- login(email: string) {
64
- return Effect.gen(async function* () {
65
- const users = yield* UserRepository
66
- const user = yield* Result.await(users.findByEmail(email))
67
-
68
- return Result.ok(user)
69
- })
70
- }
71
- }
37
+ await Runtime.make(UserRepositoryLive, backend)
38
+ // ^^^^^^^^^^^^^^^^^^
39
+ // Type error: Database is required but not provided
72
40
  ```
73
41
 
74
- `Layer.make` derives the requirements from those method return types. A
75
- `Runtime` rejects a merged Layer at compile time when one of its required
76
- Services is missing; `Layer.override` remains the explicit replacement API.
77
-
78
- `Runtime.make()` and `buildLayer()` preserve the exact Service-token union
79
- provided by the Layer. Every `run()` boundary checks the final
80
- `EffectRequirements` against that union, so a program that yields an unavailable
81
- Service is rejected at compile time and the diagnostic names
82
- `__betterEffectMissingRuntimeServices`. Programs with no Service requirements
83
- remain valid in any environment:
84
-
85
- ```ts
86
- const runtime = await Runtime.make(AppLive, backend)
87
- // inferred as Runtime<typeof Database | typeof UserRepository>
88
-
89
- type AppRuntime = RuntimeFor<typeof AppLive>
90
- // Runtime<typeof Database | typeof UserRepository>
91
-
92
- const built = await buildLayer(AppLive, backend)
93
- // inferred as BuiltLayer<typeof Database | typeof UserRepository>
94
-
95
- const result = await runtime.run(() =>
96
- Effect.gen(async function* () {
97
- const database = yield* Database
98
-
99
- return Result.ok(database.query())
100
- })
101
- )
102
- ```
103
-
104
- Use `RuntimeFor<typeof AppLive>` when a Runtime handle inferred from a Layer must
105
- be named in a function signature. This avoids manually repeating
106
- `Runtime<LayerProvided<typeof AppLive>>` while preserving the same checked
107
- Service environment.
108
-
109
- Use an unparameterized `Runtime` or `BuiltLayer` annotation only when an
110
- intentionally erased, unchecked environment is needed:
111
-
112
- ```ts
113
- const erased: Runtime = runtime
114
- ```
115
-
116
- There are no string tokens:
117
-
118
- ```ts
119
- const database = yield * Database
120
- ```
42
+ The explicit self type keeps `yield*` inference exact, while the non-empty
43
+ literal is the Service's stable logical identity. Services with identical
44
+ methods but different tags are different dependencies; use a namespaced tag
45
+ such as `@acme/Database` when identities must be shared across packages.
121
46
 
122
- The class itself is the identity used by the resolver, and the result is inferred as `Database`.
47
+ `UserRepository` used `Database`, so `Database` became part of its environment requirements.
123
48
 
124
- ### Why classes are tokens
49
+ No dependency list was written manually.
125
50
 
126
- Using constructors directly avoids duplicated identifiers such as:
51
+ Services can also describe a contract without requiring a class instance. Use the
52
+ static `of` helper to type-check a structural implementation; it returns the same
53
+ object unchanged at runtime:
127
54
 
128
55
  ```ts
129
- Service<AuthService>()('authService')
130
- ```
131
-
132
- and prevents string-key collisions or typos.
56
+ class Authorization extends Service<Authorization>()('Authorization') {
57
+ declare readonly authorize: (token: string) => Promise<boolean>
58
+ }
133
59
 
134
- Conceptually:
60
+ const authorization = Authorization.of({
61
+ authorize: async (token) => token.length > 0
62
+ })
135
63
 
136
- ```text
137
- yield* AuthService
138
-
139
-
140
- ServiceRuntime
141
-
142
-
143
- ServiceResolver
144
-
145
-
146
- AuthService instance
64
+ const AuthorizationLive = Layer.succeed(Authorization, authorization)
147
65
  ```
148
66
 
149
- ## Layer
67
+ `Authorization.of(...)` does not call a constructor or make the result an
68
+ `instanceof Authorization`. For services with constructors, private fields or
69
+ other runtime invariants, use `new Authorization(...)` instead.
150
70
 
151
- A Layer describes which implementations form an application environment.
152
-
153
- It does **not** implement dependency resolution. That remains the responsibility of the configured backend.
71
+ Provide it and the environment becomes complete:
154
72
 
155
73
  ```ts
156
- import { Layer } from 'better-effect'
157
-
158
- export const DatabaseLive = Layer.scoped(
159
- Database,
160
- async () => {
161
- const database = new Database()
74
+ const DatabaseLive = Layer.make(Database)
162
75
 
163
- await database.connect()
76
+ const AppLive = Layer.merge(DatabaseLive, UserRepositoryLive)
164
77
 
165
- return database
166
- },
167
- (database) => database.close()
168
- )
169
-
170
- export const UserRepositoryLive = Layer.make(UserRepository, () => new UserRepository())
171
-
172
- export const AppLive = Layer.merge(DatabaseLive, UserRepositoryLive)
173
- ```
174
-
175
- The core Layer API intentionally stays small:
176
-
177
- ```ts
178
- Layer.make(Service, acquire)
179
- Layer.succeed(Service, instance)
180
- Layer.scoped(Service, acquire, release)
181
- Layer.gen(Service, factory)
182
- Layer.scopedGen(Service, factory, release)
183
- Layer.merge(...layers)
184
- Layer.override(base, ...overrides)
78
+ const runtime = await Runtime.make(AppLive, backend)
185
79
  ```
186
80
 
187
- Use `Layer.gen` when constructing a provider requires contextual Services. The
188
- requirements yielded by the factory remain part of the Layer's compile-time
189
- contract:
190
-
191
- ```ts
192
- const UserRepositoryLive = Layer.gen(UserRepository, async function* () {
193
- const database = yield* Database
194
-
195
- return new UserRepository(database)
196
- })
197
- ```
81
+ And the contract does not disappear after startup.
198
82
 
199
- Use `Layer.scopedGen` when acquisition both needs contextual Services and owns a
200
- resource that must be released with the Runtime root Scope:
83
+ A Runtime also knows which Services exist in its environment:
201
84
 
202
85
  ```ts
203
- class DatabaseSession extends Service<DatabaseSession>() {
204
- constructor(readonly database: Database) {
205
- super()
206
- }
207
-
208
- close(outcome: ScopeOutcome) {
209
- return this.database.closeSession(this, outcome)
210
- }
211
- }
212
-
213
- const DatabaseSessionLive = Layer.scopedGen(
214
- DatabaseSession,
215
- async function* () {
86
+ await runtime.run(() =>
87
+ Effect.gen(async function* () {
216
88
  const database = yield* Database
217
89
 
218
- return new DatabaseSession(database)
219
- },
220
- (session, outcome) => session.close(outcome)
90
+ return Result.ok(database)
91
+ })
221
92
  )
222
93
  ```
223
94
 
224
- `Layer.gen` expresses contextual acquisition without registering cleanup;
225
- `Layer.scoped` registers cleanup for a dependency-free factory; `Layer.scopedGen`
226
- combines both. The factory remains lazy, the acquired instance is shared according
227
- to backend caching, and its release runs once when the Runtime root closes. The
228
- release callback receives the root `ScopeOutcome`, including the final outcome of
229
- one-shot `Runtime.run()`.
230
-
231
- ### Test environments
232
-
233
- Layers make implementation replacement explicit:
234
-
235
- ```ts
236
- const DatabaseTest = Layer.succeed(Database, new InMemoryDatabase())
237
-
238
- const AppTest = Layer.override(AppLive, DatabaseTest)
239
- ```
240
-
241
- ## ITI adapter
242
-
243
- `better-effect` does not depend on ITI in its core. ITI is just one possible backend.
95
+ If a program asks that Runtime for a Service its environment does not provide, TypeScript rejects the call.
244
96
 
245
- ```ts
246
- import { buildLayer } from 'better-effect'
247
-
248
- import { ItiLayerBackend } from 'better-effect/adapters/iti'
249
-
250
- const runtime = await buildLayer(AppLive, new ItiLayerBackend())
251
-
252
- try {
253
- await main()
254
- } finally {
255
- await runtime.dispose()
256
- }
97
+ ```text
98
+ yield* Database
99
+
100
+
101
+ program requires Database
102
+
103
+
104
+ Layer provides Database?
105
+
106
+ no ├──────────► TypeScript error
107
+
108
+ yes
109
+
110
+ Runtime can execute it
257
111
  ```
258
112
 
259
- This keeps application code independent from the DI container.
113
+ We call this **typechecked wiring**.
260
114
 
261
- A different backend can implement the same resolver/backend contracts without changing Services or Layers.
115
+ The Services your code uses, the implementations your Layers provide, and the programs your Runtime executes participate in the same type-level contract.
262
116
 
263
- ## Scope
117
+ ---
264
118
 
265
- `Scope` manages a dynamic lifetime containing multiple resources. Resources acquired
266
- inside `runtime.run()` belong to that execution and are released automatically when it
267
- finishes.
119
+ ## Why better-effect?
268
120
 
269
- The contextual `Scope` is a non-owning capability: it can acquire resources, register
270
- finalizers, and fork children, but it cannot close the lifetime that owns it. Owners
271
- receive a `CloseableScope` from `Scope.make()` or `scope.fork()` and remain responsible
272
- for closing it:
121
+ `better-result` already gives TypeScript applications an excellent model for typed failures.
273
122
 
274
- ```ts
275
- const scope = yield * Scope
276
- const child = scope.fork()
123
+ But typed errors are only one part of a growing application.
277
124
 
278
- try {
279
- await Scope.provide(child, () => processBatch())
280
- } finally {
281
- await child.close()
282
- }
283
- ```
125
+ Eventually you also need to answer:
284
126
 
285
- For the common acquire-and-release case, `Effect.acquireRelease()` keeps the same
286
- lifetime semantics without exposing `Scope` in the program. It is an async yieldable
287
- for `Effect.gen`:
127
+ - What does this service depend on?
128
+ - Did the application provide every dependency?
129
+ - Can this program run in this environment?
130
+ - How do I replace implementations in tests?
131
+ - Who owns this database connection?
132
+ - When should this resource be released?
288
133
 
289
- ```ts
290
- const result = await runtime.run(() =>
291
- Effect.gen(async function* () {
292
- const connection = yield* Effect.acquireRelease(
293
- () => database.reserve(),
294
- (connection, outcome) => {
295
- void outcome
296
- return connection.release()
297
- }
298
- )
299
-
300
- return Result.ok(await useConnection(connection))
301
- })
302
- )
303
- ```
134
+ Those problems are often discovered through container errors, startup failures, test setup, or manual composition-root maintenance.
304
135
 
305
- Acquisition failures are returned through the Effect `Result` error channel. The
306
- release callback is registered in the current Scope and remains Scope cleanup, so it
307
- runs when the owning execution closes.
136
+ Effect has powerful ideas for solving them.
308
137
 
309
- When a resource is already acquired and implements `Symbol.dispose` or
310
- `Symbol.asyncDispose`, `Effect.add()` registers it in the current Scope and yields the
311
- same object back to the program:
138
+ `better-effect` explores a smaller path:
312
139
 
313
- ```ts
314
- const result = await runtime.run(() =>
315
- Effect.gen(async function* () {
316
- const file = await createTemporaryFile()
317
- const ownedFile = yield* Effect.add(file)
318
-
319
- return Result.ok(await readFile(ownedFile))
320
- })
321
- )
322
- ```
140
+ **keep `better-result`, Promises and normal TypeScript — borrow the architectural ideas that make dependencies and resource lifetimes easier to reason about.**
323
141
 
324
- `Effect.add()` does not acquire the resource or create a Scope. It must run inside a
325
- managed execution such as `runtime.run()` or `Scope.run()`; without a current Scope,
326
- it preserves the existing missing-Scope failure. The resource is disposed when that
327
- Scope closes, with `Symbol.asyncDispose` preferred when both protocols exist.
142
+ ### Know your dependencies before runtime
328
143
 
329
- Use `Effect.acquireRelease()` when acquisition belongs in the Effect and cleanup needs
330
- an explicit callback or the final `ScopeOutcome`; use `Effect.add()` for an
331
- already-acquired JavaScript disposable.
144
+ Services can be requested directly:
332
145
 
333
146
  ```ts
334
- const result = await runtime.run(() =>
335
- Effect.gen(async function* () {
336
- const scope = yield* Scope
337
-
338
- const connection = await scope.acquire(
339
- () => database.reserve(),
340
- (connection, outcome) => {
341
- void outcome
342
- return connection.release()
343
- }
344
- )
345
-
346
- return Result.ok(await useConnection(connection))
347
- })
348
- )
147
+ const database = yield * Database
349
148
  ```
350
149
 
351
- Disposable objects can be registered directly:
150
+ That access is also captured by the type system.
352
151
 
353
- ```ts
354
- const file = await scope.add(await createTemporaryFile())
355
- ```
152
+ Layers know both what they provide and what their Services require. Incomplete environments can therefore fail during typechecking instead of application startup.
356
153
 
357
- `DisposableResource` requires a callable `Symbol.dispose` or `Symbol.asyncDispose`,
358
- so plain or weakly typed objects must be narrowed before registration. Dynamic values
359
- that cross an unsafe boundary are still checked at runtime and rejected with
360
- `ResourceNotDisposableError` when neither protocol is present.
154
+ Runtime keeps that environment information and checks programs against it when they run.
361
155
 
362
- Finalizers run sequentially in reverse registration order. `Layer.scoped` and
363
- `Layer.scopedGen` resources belong to the Runtime root scope and remain alive between
364
- executions; they are released when the Runtime is disposed. `Resource.acquireUseRelease()`
365
- remains available as a standalone compatibility helper implemented on top of Scope.
156
+ ### Compose application environments
366
157
 
367
- Scopes can also form explicit child lifetimes. `fork()` registers a child with its
368
- parent, while `Scope.provide()` uses an existing scope without taking ownership of its
369
- closure:
158
+ Layers describe implementations without making your application code depend on a specific DI container.
370
159
 
371
160
  ```ts
372
- const parent = Scope.make()
373
- const batch = parent.fork()
374
-
375
- try {
376
- await Scope.provide(batch, () => processBatch())
377
- } finally {
378
- await batch.close()
379
- await parent.close()
380
- }
161
+ const AppLive = Layer.merge(DatabaseLive, UserRepositoryLive, AuthServiceLive)
381
162
  ```
382
163
 
383
- `runtime.run()` creates one child scope per execution. Concurrent executions receive
384
- isolated scopes, and `runtime.dispose()` stops accepting new executions, waits for
385
- active executions to finish, then closes the Runtime root scope and its Layer resources.
386
-
387
- The final result is classified only at the execution boundary. Plain values and
388
- `Result.ok` close the execution with `{ status: 'success' }`; `Result.err` and thrown
389
- exceptions close it with `{ status: 'failure', cause }`. Intermediate Results do not
390
- change the outcome. Release callbacks receive this outcome, which makes commit/rollback
391
- cleanup possible without adding a transaction abstraction.
392
-
393
- For example, a transaction can choose its final action from the outcome:
164
+ Testing can replace implementations explicitly:
394
165
 
395
166
  ```ts
396
- const transaction =
397
- yield *
398
- Effect.acquireRelease(
399
- () => database.begin(),
400
- (transaction, outcome) =>
401
- outcome.status === 'success' ? transaction.commit() : transaction.rollback()
402
- )
403
- ```
404
-
405
- The ownership tree is:
406
-
407
- ```text
408
- Runtime
409
- └── root Scope
410
- ├── Layer resources
411
- └── execution Scope
412
- └── operation resources
167
+ const AppTest = Layer.override(AppLive, DatabaseTest)
413
168
  ```
414
169
 
415
- Graceful disposal follows this order:
170
+ The environment contract remains typed after the override.
416
171
 
417
- ```text
418
- stop accepting executions
419
-
420
- wait for active executions
421
-
422
- close the root Scope
423
-
424
- dispose the backend
425
- ```
172
+ ### Own resource lifetimes
426
173
 
427
- An execution Scope is always closed before its execution Promise settles. The precedence
428
- is `program failure > cleanup failure > program success`: a failed program preserves its
429
- exact `Result.err` or exception, while a successful program rejects with a cleanup error.
430
- Configure `onCleanupFailure` on `buildLayer` or `Runtime.make` to receive one best-effort
431
- diagnostic for suppressed execution or shutdown cleanup failures. Calls to `runtime.run()`
432
- after disposal begins fail with `BuiltLayerDisposedError` without invoking the program.
433
- Shutdown has no cancellation or timeout mechanism and is intended to be initiated outside
434
- the execution being awaited.
174
+ Some dependencies are values.
435
175
 
436
- ## Standalone resource helper
176
+ Others own connections, sessions, files or other resources.
437
177
 
438
- `Resource.acquireUseRelease()` remains useful for local workflows that want a
439
- Result-oriented acquire/use/release API without constructing a Runtime. It is
440
- implemented on top of Scope and remains fully supported; it is not deprecated.
178
+ `Layer.scoped`, `Layer.scopedGen`, `Effect.acquireRelease`, `Effect.add` and `Scope` make their lifetime explicit.
441
179
 
442
180
  ```ts
443
- import { Resource } from 'better-effect'
444
-
445
- const result = await Resource.acquireUseRelease({
446
- name: 'transaction',
447
-
448
- acquire: () => database.begin(),
449
-
450
- use: (transaction) => executeCommand(transaction),
451
-
452
- release: (transaction) => transaction.close()
453
- })
181
+ const DatabaseLive = Layer.scoped(
182
+ Database,
183
+ () => Database.connect(),
184
+ (database) => database.close()
185
+ )
454
186
  ```
455
187
 
456
- When `release` is omitted, Resource prefers the JavaScript explicit resource
457
- management protocol:
188
+ Runtime owns the application lifetime and safely releases scoped resources when that lifetime ends.
458
189
 
459
- ```ts
460
- Symbol.asyncDispose
461
- Symbol.dispose
462
- ```
190
+ Resources acquired during an individual execution belong to that execution instead.
463
191
 
464
- If both `use` and `release` fail, the `use` error is preserved. The precedence is:
192
+ ### Keep your runtime choices
465
193
 
466
- ```text
467
- 1. use failure
468
- 2. release failure
469
- 3. successful use value
470
- ```
194
+ `better-effect` is not a replacement implementation of Effect.
471
195
 
472
- Acquisition exceptions, rejected promises, and unexpected failures are normalized
473
- through `better-result`; release failures use `ResourceReleaseFailure`.
196
+ It does not introduce a fiber runtime, scheduler, streams, queues or a public `Effect<A, E, R>` abstraction.
474
197
 
475
- ## Service vs Layer vs Scope vs Resource
198
+ `Effect.gen` builds on `better-result` generator composition while carrying Service requirements through the TypeScript type system.
476
199
 
477
- | Primitive | Responsibility |
478
- | --------------- | ----------------------------------------------------- |
479
- | `Service` | Request a contextual dependency |
480
- | `Layer` | Describe the implementations that form an environment |
481
- | `Scope` | Manage dynamic lifetimes and finalizers |
482
- | `Resource` | Standalone Result-oriented acquire/use/release helper |
483
- | DI backend | Resolve and cache service instances |
484
- | `better-result` | Typed failures and generator control flow |
200
+ Dependency resolution stays behind a pluggable backend.
485
201
 
486
- ## Complete example
202
+ Your application can keep using ordinary Promises and existing libraries.
487
203
 
488
- The repository contains a TODO API example under:
204
+ ---
489
205
 
490
- ```text
491
- examples/todo-api
492
- ```
206
+ ## How it compares
493
207
 
494
- It demonstrates:
208
+ | | better-result | better-effect | Effect |
209
+ | ----------------------------- | ------------- | ------------------- | --------------- |
210
+ | Typed success/failure | ✓ | ✓ via better-result | ✓ |
211
+ | Generator composition | ✓ | ✓ | ✓ |
212
+ | Contextual Services | — | ✓ | ✓ |
213
+ | Dependency requirements | — | ✓ | ✓ |
214
+ | Checked environments | — | ✓ | ✓ |
215
+ | Scoped resource lifetimes | — | ✓ | ✓ |
216
+ | Pluggable external DI backend | — | ✓ | different model |
217
+ | Fiber runtime | — | — | ✓ |
218
+ | Structured concurrency | — | — | ✓ |
219
+ | Streams / queues / schedules | — | — | ✓ |
220
+ | Full effect ecosystem | — | — | ✓ |
495
221
 
496
- - Bun HTTP server
497
- - SQLite in memory with `Bun.SQL`
498
- - user login
499
- - session authentication
500
- - TODO CRUD
501
- - `Service` dependency access
502
- - Layer composition
503
- - Runtime root and execution scopes
504
- - scoped database lifecycle
505
- - Standalone `Resource` compatibility API
506
- - ITI as the DI backend
222
+ ### Choose `better-result`
507
223
 
508
- Run it from the repository root:
224
+ When typed error handling and Result composition are enough.
509
225
 
510
- ```bash
511
- bun examples/todo-api/index.ts
512
- ```
513
-
514
- ## Development
226
+ ### Add `better-effect`
515
227
 
516
- Install dependencies:
228
+ When your Result-based application also needs contextual Services, typechecked application wiring, composable environments, or resource lifetime management.
517
229
 
518
- ```bash
519
- bun install
520
- ```
230
+ ### Choose Effect
521
231
 
522
- Run tests:
232
+ When you want a complete effect system and its runtime, concurrency model, dependency model, resource management and broader ecosystem.
523
233
 
524
- ```bash
525
- bun test
526
- ```
234
+ `better-effect` is inspired by some of those ideas. It is intentionally not a reimplementation of the whole system.
527
235
 
528
- Typecheck:
236
+ ---
529
237
 
530
- ```bash
531
- bun run typecheck
532
- ```
238
+ ## Core ideas
533
239
 
534
- Run the full quality gate:
240
+ ### Typechecked wiring
535
241
 
536
- ```bash
537
- bun run check
538
- ```
242
+ **Service requirements → Layer completeness → Runtime validation**
539
243
 
540
- The project uses:
244
+ Use a Service and its requirement follows the program.
541
245
 
542
- - Bun as package manager and test runner
543
- - TypeScript
544
- - tsdown for library builds
545
- - Oxlint
546
- - Oxfmt
547
- - publint
246
+ Build an incomplete environment and TypeScript tells you what is missing.
548
247
 
549
- ## Design principles
248
+ Run a program against an incompatible Runtime and the mismatch remains visible at compile time.
550
249
 
551
- ### Keep the core small
250
+ ### Composable environments
552
251
 
553
- `better-effect` should not grow into a second Effect runtime.
252
+ **Layer merge override DI backend**
554
253
 
555
- The core intentionally does not implement:
254
+ Describe application implementations independently from the container responsible for resolving them.
556
255
 
557
- - fibers
558
- - schedules
559
- - streams
560
- - queues
561
- - a dependency graph runtime
562
- - a custom DI container
563
- - a custom Context
564
- - `Effect<A, E, R>`
256
+ Compose production environments and replace selected implementations for tests.
565
257
 
566
- ### Delegate instead of rebuilding
258
+ ### Scoped lifetimes
567
259
 
568
- Dependency resolution and caching belong to DI backends. Service release lifecycle is
569
- owned by `Scope`.
260
+ **Scope scoped Layers acquire/release graceful Runtime disposal**
570
261
 
571
- `better-effect` supplies the protocol and composition primitives.
262
+ Make ownership explicit for resources that need cleanup.
572
263
 
573
- ### Preserve inference at public boundaries
264
+ Application resources live with the Runtime. Execution resources live with the execution.
574
265
 
575
- Type safety is part of the API.
266
+ ### better-result underneath
576
267
 
577
- For example:
268
+ **Result → Result.gen → Effect.gen → pipe**
578
269
 
579
- ```ts
580
- const auth = yield * AuthService
581
- // AuthService
582
- ```
270
+ Keep `better-result` as the source of truth for typed successes, failures, short-circuiting
271
+ and generator control flow. `Effect.gen` delegates to `Result.gen`; it adds only the
272
+ phantom Service requirements that TypeScript needs to check the application environment.
273
+ At runtime, an `EffectResult` is still a `better-result` Result; the requirements exist
274
+ only in the type.
583
275
 
584
- and:
276
+ For a linear workflow, `pipe` composes the same kind of program without introducing a
277
+ second Result model or a lazy Effect runtime:
585
278
 
586
279
  ```ts
587
- const database = await ServiceRuntime.resolve(Database)
588
- // Database
589
- ```
590
-
591
- must preserve exact instance types.
280
+ import { Effect, pipe } from 'better-effect'
592
281
 
593
- ### Type erasure stays internal
594
-
595
- Layers may store heterogeneous providers internally using erased types.
596
-
597
- The public constructors (`Layer.make`, `Layer.succeed`, `Layer.scoped`) are responsible for preserving the relationship between a Service token and its instance type.
598
-
599
- ## Current scope
600
-
601
- The project is intentionally small and experimental.
602
-
603
- The initial scope is:
604
-
605
- ```text
606
- Service
607
- Layer
608
- Scope
609
- Resource
610
- DI adapters
611
- better-result integration
282
+ const program = pipe(
283
+ findUser(id),
284
+ Effect.map((user: User) => user.email),
285
+ Effect.andThen(loadPermissions),
286
+ Effect.mapError((cause: LoadUserError | PermissionError) => new ApplicationError({ cause }))
287
+ )
612
288
  ```
613
289
 
614
- New abstractions should only be added when they solve a concrete problem without duplicating responsibilities already handled well by another library.
615
-
616
- ## License
290
+ The combinators keep the `better-result` semantics: `Effect.map` changes the success
291
+ type, `Effect.mapError` changes the error type, and `Effect.andThen` only calls the next
292
+ step after an `Ok`. Use `Effect.andThenAsync` when the next operation returns a
293
+ `Promise<Result>`; it always returns a Promise, including when the source is synchronous
294
+ or already an `Err`. The pipeline carries the requirements of every step, so Runtime
295
+ still rejects it when its Layer does not provide every required Service.
617
296
 
618
- MIT
297
+ Use `Effect.gen` for larger workflows with several intermediate values, branches or
298
+ procedural logic. Use `pipe` for concise, linear composition; both are ways to compose
299
+ `better-result` programs while keeping dependency checking in the `better-effect` layer.