Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Low-value wrappers:
Development guidance:
- Prefer existing patterns in `src/Fx.ts`, `src/Effect.ts`, and `src/Handler.ts`.
- Preserve strong effect typing. Changes should keep `E` unions meaningful and narrowed by handlers.
- Default global scope discipline:
- `GlobalScope` is a real exported scope value, not an ambient capability.
- Choose `GlobalScope` only when the producer and handler are owned together in local app code.
- Choose a named scope when the scope is part of a reusable or composable boundary.
- Prefer explicit named scopes in reusable functions, public APIs, libraries, nested control regions, resources, retries, and examples that teach composition.
- Do not broaden default-scope overloads to every scoped effect by default; add focused runtime and type tests for each new default-scope API.
- Keep default-scope requirements visible in types as `typeof GlobalScope` rather than hiding scope requirements behind ambient state.
- Do not use `GlobalScope` as a catch-all for unrelated control effects; a global-scope handler may catch exits it did not intend to own.
- Use `Fail<E>` for recoverable errors. In fx code, JS `throw` is reserved for intentionally hard-crashing the program, enforcing internal invariants, or clearly named unsafe/assert APIs such as `Fail.assert`.
- At runtime and platform boundaries, convert rejected promises, thrown platform errors, and recoverable exceptional states into `Fail` with `tryPromise`, `trySync`, or `fail`. Recover with `catchAll`, `catchOnly`, or `catchIf` rather than throwing from handlers.
- Organize source modules from higher-level public constructs to lower-level implementation details: effect declarations first, public constructors/handlers next, exported support types after that, and internal helpers last.
Expand Down
15 changes: 15 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ const main = program.pipe(
Do not hide large handler stacks behind broad framework-like layers unless the
named boundary is real and useful for the application.

## Default global scope

`GlobalScope` is a real typed scope value, not ambient state. Use the default
global scope when the program and its handler are owned together in local
application code and there is only one intended control region. Default scope
overloads such as `scope()`, `abort()`, `orReturn(value)`, and
`returnFrom(value)` keep that case compact while preserving the effect
requirement in the type.

Use a named scope when the control region has an owner that should remain
visible to callers: reusable helpers, public APIs, nested regions, resources,
retries, or examples that teach handler composition. A handler for the global
scope can catch any same-scope exit, so do not use it as a catch-all for
unrelated control effects.

## Choose the right runner

- Use `run` only after all async, failure, handler-capture, and platform effects
Expand Down
4 changes: 4 additions & 0 deletions docs/recipes/manage-resources-and-finalizers.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const program = fx(function* () {
Use `using`, `usingExit`, or `usingManaged` to acquire and register cleanup in a
small uninterruptible region.

Use `scope()` only for small local programs with one obvious cleanup or control
region. Prefer named scopes when resources are nested, reusable, or exposed from
a helper.

Handler pipeline:

```ts
Expand Down
67 changes: 67 additions & 0 deletions docs/recipes/use-scoped-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Use Scoped Control

Use this when a computation needs to exit a delimited region early.

Use the default global scope when the program and its handler are owned together
in local application code and there is only one intended control region. It
keeps that case compact while preserving the effect requirement in the type.

```ts
import { abort, orReturn } from "@briancavalier/fx/Abort"
import { fx, run } from "@briancavalier/fx"
import { scope } from "@briancavalier/fx/Scope"

const loadUser = (id: string | undefined) => fx(function* () {
if (id === undefined) yield* abort()

return yield* fetchUser(id)
})

const user = loadUser(input.id).pipe(
scope(),
orReturn(guestUser),
run
)
```

Use an explicit named scope when the control region has an owner that should
remain visible to callers. That includes reusable helpers, public APIs, nested
regions, resources, retries, and examples that teach handler composition.

```ts
import { abort, orReturn, restartOnAbort } from "@briancavalier/fx/Abort"
import { fx, run } from "@briancavalier/fx"
import { returnFrom } from "@briancavalier/fx/ReturnFrom"
import { scope } from "@briancavalier/fx/Scope"

const RequestScope = "app/Request" as const

const loadRequest = (request: Request) => fx(function* () {
if (!request.authorized) yield* returnFrom(RequestScope, unauthorized)

const user = yield* loadUser(request.userId)
if (user.disabled) yield* abort(RequestScope)

return okResponse(user)
})

const response = loadRequest(request).pipe(
restartOnAbort(RequestScope, { restarts: 1 }),
scope(RequestScope),
orReturn(RequestScope, unavailable),
run
)
```

`restartOnAbort` takes an explicit scope. Retryable control has a visible owner,
so do not treat the global scope as its default.

For `YieldFrom`, use `yieldScope<Out, In>()("scope/name")` when one named scope
has one yield protocol. When one runtime scope intentionally carries multiple
protocols, compose protocol-map `Yielding<Protocol, Out, In>` brands on that
scope. Output types compose by union and input types compose by intersection.
Prefer explicit scopes when request and response protocols need tighter
correlation.

Common mistake: using the global scope as a catch-all for unrelated exits. A
global-scope handler can catch any exit that uses the same global scope.
8 changes: 4 additions & 4 deletions examples/intermediate/read-csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { assert as assertNoFail } from '../../src/Fail.js'
import { managed, usingManaged } from '../../src/Finalization.js'
import { handleScoped } from '../../src/Handler.js'
import { returnFrom } from '../../src/ReturnFrom.js'
import { brand, scope } from '../../src/Scope.js'
import { yieldFrom, YieldFrom, type Yielding } from '../../src/YieldFrom.js'
import { scope } from '../../src/Scope.js'
import { yieldFrom, YieldFrom, yieldScope } from '../../src/YieldFrom.js'

const ImportCsv = 'examples/intermediate/ImportCsv' as const

Expand All @@ -25,8 +25,8 @@ type IndexedCsvRow = {
readonly value: CsvRow
}

const CsvRows = brand<Yielding<CsvRow>>()('examples/intermediate/CsvRows')
const IndexedCsvRows = brand<Yielding<IndexedCsvRow>>()('examples/intermediate/IndexedCsvRows')
const CsvRows = yieldScope<CsvRow>()('examples/intermediate/CsvRows')
const IndexedCsvRows = yieldScope<IndexedCsvRow>()('examples/intermediate/IndexedCsvRows')

const stopImport = (reason: string) =>
returnFrom(ImportCsv, { type: 'skipped', reason } satisfies ImportResult)
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
"types": "./dist/Fail.d.ts",
"import": "./dist/Fail.js"
},
"./GlobalScope": {
"types": "./dist/GlobalScope.d.ts",
"import": "./dist/GlobalScope.js"
},
"./Concurrent": {
"types": "./dist/Concurrent.d.ts",
"import": "./dist/Concurrent.js"
Expand Down
31 changes: 30 additions & 1 deletion src/Abort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,29 @@ import { fail, Fail, returnFail } from './Fail.js'
import { andFinally } from './Finalization.js'
import { fx, ok, run, type Fx } from './Fx.js'
import { returnFrom } from './ReturnFrom.js'
import { scope } from './Scope.js'
import { scope, GlobalScope } from './Scope.js'

describe('Abort', () => {
const TestScope = 'test/Abort' as const

describe('scope', () => {
it('defaults to the global scope', () => {
const r = Math.random()
const a = abort().pipe(scope(), orReturn(r), run)

assert.equal(a, r)
})

it('leaves default Abort unhandled when global fallback is omitted', () => {
const f = abort().pipe(scope())
const _: typeof f extends import('./Fx.js').Fx<Abort<typeof GlobalScope>, never> ? true : false = true

const next = f[Symbol.iterator]().next()

assert.equal(Abort.is(next.value), true)
assert.equal((next.value as Abort<typeof GlobalScope>).scope, GlobalScope)
})

it('given matching Abort with fallback, returns alternative', () => {
const r = Math.random()
const a = abort(TestScope).pipe(scope(TestScope), orReturn(TestScope, r), run)
Expand Down Expand Up @@ -43,6 +60,18 @@ describe('Abort', () => {

assert.equal(Abort.is(f[Symbol.iterator]().next().value), true)
})

it('does not handle explicit Abort with the global fallback', () => {
const f = fx(function* () {
yield* abort(TestScope)
return 'done'
}).pipe(scope(TestScope), orReturn('aborted'))

const next = f[Symbol.iterator]().next()

assert.equal(Abort.is(next.value), true)
assert.equal((next.value as Abort<typeof TestScope>).scope, TestScope)
})
})

describe('restartOnAbort', () => {
Expand Down
30 changes: 22 additions & 8 deletions src/Abort.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { at } from './Breadcrumb.js'
import { ScopedEffect, withOrigin } from './Effect.js'
import { Fx, fx, ok } from './Fx.js'
import { GlobalScope } from './GlobalScope.js'
import { control } from './Handler.js'
import { scope as scoped, type ReturnValue, type ScopeEffects } from './Scope.js'

Expand All @@ -9,22 +10,35 @@ import { scope as scoped, type ReturnValue, type ScopeEffects } from './Scope.js
*/
export class Abort<const Scope extends string> extends ScopedEffect('fx/Abort')<Scope, void, never> { }

export const abort = <const Scope extends string>(scope: Scope): Fx<Abort<Scope>, never> =>
withOrigin(new Abort(scope, undefined), at('fx/Abort/abort', abort))
export function abort(): Fx<Abort<typeof GlobalScope>, never>
export function abort<const Scope extends string>(scope: Scope): Fx<Abort<Scope>, never>
export function abort(scope: string = GlobalScope): Fx<Abort<any>, never> {
return withOrigin(new Abort(scope, undefined), at('fx/Abort/abort', abort))
}

/**
* Return a default value from an abort of the named scope.
*/
export const orReturn = <const Scope extends string, const R>(
export function orReturn<const R>(
value: R
): <const E, const A>(f: Fx<E, A>) => Fx<Exclude<E, Abort<typeof GlobalScope>>, A | R>
export function orReturn<const Scope extends string, const R>(
scope: Scope,
value: R
) => <const E, const A>(
f: Fx<E, A>
): Fx<Exclude<E, Abort<Scope>>, A | R> =>
): <const E, const A>(f: Fx<E, A>) => Fx<Exclude<E, Abort<Scope>>, A | R>
export function orReturn(
scopeOrValue: unknown,
maybeValue?: unknown
): unknown {
const scope = arguments.length === 1 ? GlobalScope : scopeOrValue as string
const value = arguments.length === 1 ? scopeOrValue : maybeValue

return <const E, const A>(f: Fx<E, A>): Fx<Exclude<E, Abort<string>>, unknown> =>
f.pipe(
control(Abort, (_, abort) =>
(abort.scope === scope ? ok(value) : abort) as Fx<Exclude<E, Abort<Scope>>, A | R>)
) as Fx<Exclude<E, Abort<Scope>>, A | R>
(abort.scope === scope ? ok(value) : abort) as Fx<Exclude<E, Abort<string>>, unknown>)
) as Fx<Exclude<E, Abort<string>>, unknown>
}

export interface RestartOnAbortOptions {
/**
Expand Down
6 changes: 3 additions & 3 deletions src/Finalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { fx, ok, run, type Fx } from './Fx.js'
import { andFinally, andFinallyExit, managed, using, usingExit, usingManaged, type Finally, type Managed } from './Finalization.js'
import type { Interrupt } from './Interrupt.js'
import { returnFrom } from './ReturnFrom.js'
import { brand, scope, type Exit } from './Scope.js'
import { collectFrom, YieldFrom, yieldFrom, type Yielding } from './YieldFrom.js'
import { scope, type Exit } from './Scope.js'
import { collectFrom, YieldFrom, yieldFrom, yieldScope } from './YieldFrom.js'

describe('Finalization', () => {
const TestScope = 'test/Finalization' as const
const CleanupEvents = brand<Yielding<'cleanup'>>()('test/Finalization/cleanup')
const CleanupEvents = yieldScope<'cleanup'>()('test/Finalization/cleanup')

it('preserves finalizer effects in constructor types', () => {
const releaseFailure = new Error('release failed')
Expand Down
4 changes: 4 additions & 0 deletions src/GlobalScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Default scope for local application code that does not need a named scope.
*/
export const GlobalScope = 'fx/Scope/Global' as const
28 changes: 27 additions & 1 deletion src/ReturnFrom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@ import * as assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { fx, run } from './Fx.js'
import { ReturnFrom, returnFrom } from './ReturnFrom.js'
import { scope } from './Scope.js'
import { scope, GlobalScope } from './Scope.js'

describe('ReturnFrom', () => {
const TestScope = 'test/ReturnFrom' as const

it('defaults to returning early from the global scope', () => {
const result = fx(function* () {
yield* returnFrom('early')
return 'late'
}).pipe(
scope(),
run
)

assert.equal(result, 'early')
})

it('returns early from the matching scope with a value', () => {
const result = fx(function* () {
yield* returnFrom(TestScope, 'early')
Expand All @@ -19,6 +31,20 @@ describe('ReturnFrom', () => {
assert.equal(result, 'early')
})

it('propagates global returnFrom through an explicit scope', () => {
const f = fx(function* () {
yield* returnFrom('global')
return 'late'
}).pipe(scope(TestScope))

const next = f[Symbol.iterator]().next()

assert.equal(ReturnFrom.is(next.value), true)
const effect = next.value as ReturnFrom<typeof GlobalScope, 'global'>
assert.equal(effect.scope, GlobalScope)
assert.equal(effect.arg, 'global')
})

it('does not run code after returnFrom', () => {
let ran = false

Expand Down
17 changes: 14 additions & 3 deletions src/ReturnFrom.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { ScopedEffect } from './Effect.js'
import { Fx } from './Fx.js'
import { GlobalScope } from './GlobalScope.js'

/**
* Return early from the named scope with a value.
*/
export class ReturnFrom<const Scope extends string, const A> extends ScopedEffect('fx/ReturnFrom')<Scope, A, never> { }

export const returnFrom = <const Scope extends string, const A>(
export function returnFrom<const A>(
value: A
): Fx<ReturnFrom<typeof GlobalScope, A>, never>
export function returnFrom<const Scope extends string, const A>(
scope: Scope,
value: A
): Fx<ReturnFrom<Scope, A>, never> =>
new ReturnFrom(scope, value)
): Fx<ReturnFrom<Scope, A>, never>
export function returnFrom(
scopeOrValue: unknown,
maybeValue?: unknown
): Fx<ReturnFrom<string, unknown>, never> {
return arguments.length === 1
? new ReturnFrom(GlobalScope, scopeOrValue)
: new ReturnFrom(scopeOrValue as string, maybeValue)
}
11 changes: 9 additions & 2 deletions src/Scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { isEffect } from './Effect.js'
import { Fail, fail, returnFail } from './Fail.js'
import { Finalizer, Finally } from './Finalization.js'
import { Fx, fx } from './Fx.js'
import { GlobalScope } from './GlobalScope.js'
import { CapturedHandler, HandlerCapture } from './HandlerCapture.js'
import { ReturnFrom } from './ReturnFrom.js'
import { drainIteratorReturn, isInterpretingReturn } from './internal/iteratorClose.js'
Expand All @@ -13,6 +14,8 @@ export const brand = <Brand>() =>
<const Name extends string>(name: Name): Name & Brand =>
name as Name & Brand

export { GlobalScope }

export type Exit<
Scope extends string = string,
A = unknown,
Expand Down Expand Up @@ -51,11 +54,15 @@ export interface Interrupted<Scope extends string> {
readonly scope: Scope
}

export function scope(): <const E, const A>(f: Fx<E, A>) => Fx<ScopeEffects<E, typeof GlobalScope>, A | ReturnValue<E, typeof GlobalScope>>
export function scope<const Scope extends string>(
name: Scope
): <const E, const A>(f: Fx<E, A>) => Fx<ScopeEffects<E, Scope>, A | ReturnValue<E, Scope>> {
): <const E, const A>(f: Fx<E, A>) => Fx<ScopeEffects<E, Scope>, A | ReturnValue<E, Scope>>
export function scope(
name: string = GlobalScope
): unknown {
return <const E, const A>(f: Fx<E, A>) =>
new ScopeBoundary(f, name) as Fx<ScopeEffects<E, Scope>, A | ReturnValue<E, Scope>>
new ScopeBoundary(f, name) as Fx<ScopeEffects<E, string>, A | ReturnValue<E, string>>
}

export type ScopeEffects<E, Scope extends string> =
Expand Down
Loading
Loading