Skip to content

@umpire/effect

@umpire/effect bridges Umpire’s availability map and Effect’s Schema system. Disabled fields are excluded from validation. Required/optional follows Umpire’s output, not your schema definitions. The adapter is Effect-first: use runValidate(...), runEffect(...), or manual decodeEffectSchema(...) inside Effect.gen. Sync APIs are available only for context-free schemas. It also offers a SubscriptionRef bridge, reactive Stream generators, and Layer constructors for wiring Umpire into the Effect service environment.

Terminal window
yarn add @umpire/core @umpire/effect effect

effect is a peer dependency — bring your own Effect v4 beta/stable release.

deriveSchema(availability, schemas, options?)

Section titled “deriveSchema(availability, schemas, options?)”

Builds a Schema.Struct from the availability map:

  • Disabled fields — excluded from the schema entirely
  • Enabled + required — field uses the base schema as-is
  • Enabled + optional — field is wrapped with Schema.optional()
  • Foul fields — see rejectFoul below
import { Effect, Schema } from 'effect'
import {
decodeEffectSchema,
decodeEffectSchemaSync,
deriveSchema,
} from '@umpire/effect'
const fieldSchemas = {
email: Schema.String.check(
Schema.makeFilter((s) =>
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)
? undefined
: 'Enter a valid email',
),
),
companyName: Schema.String.check(
Schema.makeFilter((s) =>
s.length > 0 ? undefined : 'Company name is required',
),
),
companySize: Schema.String,
}
const availability = ump.check(values, conditions)
const schema = deriveSchema(availability, fieldSchemas)
const program = Effect.gen(function* () {
return yield* decodeEffectSchema(schema, values)
})

deriveSchema preserves the R parameter from your field schemas. If any field schema requires a service, the returned struct schema carries that requirement.

For manual composition, build the availability-aware schema with deriveSchema(). Decode it with decodeEffectSchema() inside an Effect workflow. If the schema has no service requirement and you need a plain result, use decodeEffectSchemaSync().

Fields where fair: false hold values that were once valid but are now contextually wrong — a selection that no longer fits the current state. By default these pass through with their base schema (useful on the client while the form is active). On a server, you may want to reject them outright:

// Server handler — rejects any submission containing a foul value
const availability = engine.check(body)
const schema = deriveSchema(availability, fieldSchemas, { rejectFoul: true })
const program = Effect.gen(function* () {
return yield* decodeEffectSchema(schema, body)
})

When rejectFoul: true, a foul field with a present value fails with the field’s reason as the error message. If the field is optional and absent, it passes — only submissions that contain a foul value are rejected.

decodeEffectSchema(schema, input, options?)

Section titled “decodeEffectSchema(schema, input, options?)”

Effect-first schema decoding. Use this in Effect.gen with the schema returned by deriveSchema(), including schemas with service dependencies (R ≠ never):

import { Effect } from 'effect'
import { decodeEffectSchema, deriveSchema } from '@umpire/effect'
const schema = deriveSchema(availability, fieldSchemas)
// schema may carry R from field schemas with service dependencies
const program = Effect.gen(function* () {
const result = yield* decodeEffectSchema(schema, values, { errors: 'all' })
if (result._tag === 'Left') {
// handle errors
}
return result
})

decodeEffectSchemaSync(schema, input, options?)

Section titled “decodeEffectSchemaSync(schema, input, options?)”

Plain synchronous schema decoding for context-free schemas only. Use this only when you explicitly need a plain result and the schema has no Effect service requirement (R = never):

import { decodeEffectSchemaSync, deriveSchema } from '@umpire/effect'
const schema = deriveSchema(availability, fieldSchemas)
const result = decodeEffectSchemaSync(schema, values, { errors: 'all' })

decodeEffectSchemaSync cannot handle service-requiring schemas. Serviceful Effect schemas should use decodeEffectSchema, runEffect, or runValidate.

Normalizes an Effect schema parse error or issue into { field, message }[] pairs.

const result = decodeEffectSchemaSync(schema, values)
if (result._tag === 'Left') {
const pairs = effectErrors(result.error)
// [{ field: 'email', message: 'Enter a valid email' }, ...]
}

Filters normalized error pairs to only include enabled fields and keeps the first message per field. Returns Partial<Record<string, string>>. Root-level errors from cross-field refinements are keyed under '_root'.

const errors = deriveErrors(availability, effectErrors(result.error))
// { email: 'Enter a valid email' }
// companyName omitted if disabled on the current plan

createEffectAdapter()({ schemas, build?, valueShape?, namespace?, rejectFoul? })

Section titled “createEffectAdapter()({ schemas, build?, valueShape?, namespace?, rejectFoul? })”

Convenience adapter that bundles the deriveSchema → decode → deriveErrors flow. The adapter provides different members depending on whether your schemas have service dependencies.

When all schemas are context-free (R = never):

  • validators — per-field validators for umpire({ validators }), surfacing the first parse issue as error
  • run(availability, values) — full validation returning { errors, normalizedErrors, result, schemaFields }

Always available:

  • runEffect(availability, values) — returns Effect<EffectAdapterRunResult, never, R>. Works with any R.
  • runValidate(availability, values) — returns Effect<Out, UmpireValidationError, R>. Succeeds with the parsed output, fails with UmpireValidationError on failure. Works with any R.

When your schemas have service dependencies (R ≠ never), validators and run are not present on the returned adapter. You get a TypeScript error at the call site. Use runEffect or runValidate instead.

const validation = createEffectAdapter()({
schemas: {
email: Schema.String,
companyName: Schema.String,
},
})
// Per-field validators for inline validation
const ump = umpire({
fields,
rules,
validators: validation.validators,
})
// Full derived-schema validation (sync)
const result = validation.run(availability, values)
if (result.result._tag === 'Left') {
console.log(result.errors) // { email: 'Enter a valid email' }
console.log(result.schemaFields) // ['email'] — disabled fields excluded
}

Effectful example (service-requiring schemas)

Section titled “Effectful example (service-requiring schemas)”
import { Effect, Schema } from 'effect'
import { createEffectAdapter, UmpireValidationError } from '@umpire/effect'
const fieldSchemas = {
username: Schema.String,
email: Schema.String.pipe(
Schema.filterEffect((s: string) =>
Effect.gen(function* () {
const repo = yield* UserRepo
const exists = yield* repo.findByEmail(s)
return !exists
}),
),
{ message: () => 'Email already taken' },
),
}
const validation = createEffectAdapter()({ schemas: fieldSchemas })
// validation.validators — not available (R ≠ never)
// validation.run — not available (R ≠ never)
// Use the effectful methods instead
const program = validation.runValidate(availability, values).pipe(
Effect.catchTag('UmpireValidationError', (error) =>
Effect.succeed({ errors: error.errors }),
),
)
Effect.runPromise(program.pipe(Effect.provideService(UserRepo, myRepo)))

Use build to add cross-field refinements on the derived schema:

const validation = createEffectAdapter()({
schemas: {
password: Schema.String,
confirmPassword: Schema.String,
},
build: (base) =>
base.check(
Schema.makeFilter((data) =>
(data as Record<string, unknown>).password ===
(data as Record<string, unknown>).confirmPassword
? undefined
: 'Passwords do not match',
),
),
})

The root-level refinement error surfaces under result.errors._root.

For manual composition, build the availability-aware schema with deriveSchema(). Decode it with decodeEffectSchema() inside an Effect workflow. If the schema has no service requirement and you need a plain result, use decodeEffectSchemaSync().

toAsyncWriteValidationAdapter(adapter, run)

Section titled “toAsyncWriteValidationAdapter(adapter, run)”

Adapts an Effect validation adapter to @umpire/write’s async validation protocol. Use this when serviceful Effect schemas need to participate in async write or Drizzle checks:

import { Effect } from 'effect'
import { checkCreateAsync, runWriteValidationAdapterAsync, composeWriteResult } from '@umpire/write'
import {
createEffectAdapter,
toAsyncWriteValidationAdapter,
} from '@umpire/effect'
const validation = createEffectAdapter()({ schemas })
const writeValidation = toAsyncWriteValidationAdapter(validation, (effect) =>
Effect.runPromise(Effect.provide(effect, LiveLayer)),
)
const write = await checkCreateAsync(policy, data)
const validationRun = await runWriteValidationAdapterAsync(
writeValidation,
write.availability,
write.candidate
)
const result = composeWriteResult({ write, validation: validationRun })

The runner is supplied by your app so you control service provisioning. For context-free schemas, Effect.runPromise is enough.

When field keys are namespaced with a separator — for example account.email and account.name from Drizzle models — the flat key-value record does not match the nested object structure a schema expects. Set valueShape: 'nested' to restructure values before validation:

import { createEffectAdapter } from '@umpire/effect'
import { Schema } from 'effect'
const adapter = createEffectAdapter()({
schemas: {
'account.email': Schema.String,
'account.name': Schema.String,
},
valueShape: 'nested', // default: 'flat'
namespace: { separator: '.' }, // default separator: '.'
build: (base) =>
Schema.Struct({
account: Schema.Struct({
email: Schema.String,
name: Schema.String,
}),
}),
})

When valueShape is 'nested':

  1. The flat candidate { 'account.email': 'x', 'account.name': 'y' } is nested into { account: { email: 'x', name: 'y' } } via nestNamespacedValues from @umpire/write.
  2. The nested object is validated against the schema (after build transforms the derived base schema).
  3. Error paths are flattened back to flat field keys via flattenFieldErrorPaths.

build is required when valueShape is 'nested'. The derived per-field schema uses flat field keys (e.g. 'account.email'), but validation runs against a nested object. The build callback is where you create a schema that matches that nested structure. Without it, the adapter throws a configuration error.

namespace.separator controls which character splits field names into path segments and defaults to '.'.

A tagged error class thrown by runValidate on validation failure. It carries the structured error information from the failed validation run:

import { Data } from 'effect'
import type { NormalizedFieldError } from '@umpire/effect'
// The shape
class UmpireValidationError extends Data.TaggedError('UmpireValidationError')<{
readonly errors: Record<string, string | undefined>
readonly message: string
readonly normalizedErrors: NormalizedFieldError[]
}>

Use Effect.catchTag to handle it:

validation.runValidate(availability, values).pipe(
Effect.catchTag('UmpireValidationError', (error) => {
console.log(error.message) // 'Validation failed: email, password'
console.log(error.errors) // { email: 'Enter a valid email', password: undefined }
console.log(error.normalizedErrors) // [{ field: 'email', message: '...' }, ...]
return Effect.succeed({ errors: error.errors })
}),
)

error.errors is a Record<string, string | undefined> — one entry per field. A field that passed validation has undefined as its value. Only fields with errors get message strings.

Returns an Effect Stream<AvailabilityMap<F>, never, never> from a SubscriptionRef. Each time the ref changes, the stream emits a fresh availability map computed by ump.check(). The stream’s error channel is never because @umpire/core checks are synchronous and never reject.

import { Effect, Stream, SubscriptionRef } from 'effect'
import { enabledWhen, umpire } from '@umpire/core'
import { availabilityStream } from '@umpire/effect'
const ump = umpire({
fields: { name: {}, email: {} },
rules: [enabledWhen('email', (_v, c: { showEmail: boolean }) => c.showEmail)],
})
const ref = Effect.runSync(SubscriptionRef.make({ showEmail: false }))
const stream = availabilityStream(ump, ref, {
select: () => ({}),
conditions: (state) => state,
})
// The first emission is a fresh check (no previous values).
// Subsequent emissions use the previous values for diff-aware rules.
const history = Effect.runSync(Stream.runCollect(stream.pipe(Stream.take(2))))
// history has 2 availability snapshots: [initial, after-first-change]

The select and conditions options follow the same contract as @umpire/store. See Selection.

availabilityStreamAsync(ump, ref, options)

Section titled “availabilityStreamAsync(ump, ref, options)”

Same as availabilityStream but for @umpire/async instances. The stream’s error channel is unknown because @umpire/async checks are promise-based and can reject:

import { availabilityStreamAsync } from '@umpire/effect'
import { umpire as asyncUmpire } from '@umpire/async'
const asyncUmp = asyncUmpire({
fields: { name: {}, email: {} },
rules: [enabledWhen('email', (_v, c: { showEmail: boolean }) => c.showEmail)],
})
const stream = availabilityStreamAsync(asyncUmp, ref, {
select: () => ({}),
conditions: (state) => state,
})
// Stream<AvailabilityMap<F>, unknown, never>

If a check rejects, the stream fails with that error. Handle it with Stream.catchAll or Stream.orElse.

Creates an Effect Layer that provides an @umpire/core Umpire instance as a service. Use this to wire Umpire into your Effect service environment for dependency injection:

import { Context, Effect, Layer } from 'effect'
import { enabledWhen } from '@umpire/core'
import { umpireLayer } from '@umpire/effect'
class UmpireService extends Context.Tag('UmpireService')<
UmpireService,
ReturnType<typeof umpire>
>() {}
const layer = umpireLayer(UmpireService, {
fields: { name: {}, email: {} },
rules: [
enabledWhen('email', (_v, c: { showEmail: boolean }) => c.showEmail),
],
})
const program = Effect.gen(function* () {
const ump = yield* UmpireService
const availability = ump.check({ name: 'Jane' }, { showEmail: true })
// ...
})
Effect.runPromise(program.pipe(Effect.provide(layer)))

The layer is built with Layer.sync — the Umpire instance is constructed eagerly when the layer is provided.

Same as umpireLayer but for @umpire/async instances:

import { umpireAsyncLayer } from '@umpire/effect'
const asyncLayer = umpireAsyncLayer(AsyncUmpireService, {
fields: { name: {}, email: {} },
rules: [enabledWhen('email', (_v, c: { showEmail: boolean }) => c.showEmail)],
})

@umpire/effect draws a clean line between sync and effectful APIs. This table summarizes which APIs handle service-requiring Effect schemas:

APIRequires R = never?Handles service-requiring schemas?
deriveSchema()No — preserves RYes
decodeEffectSchema()NoYes
decodeEffectSchemaSync()YesNo
createEffectAdapter().validatorsYesNo
createEffectAdapter().run()YesNo
createEffectAdapter().runEffect()NoYes
createEffectAdapter().runValidate()NoYes
availabilityStream()N/A (no schemas)N/A
availabilityStreamAsync()N/A (no schemas)N/A
umpireLayer() / umpireAsyncLayer()N/A (no schemas)N/A

deriveSchema itself preserves the R parameter from your field schemas. If a field schema requires a service (e.g. a repository for uniqueness checks), the struct schema returned by deriveSchema will require it too. You can feed that schema directly to decodeEffectSchema, runEffect, or runValidate — all of which support the full R channel.

The sync APIs (decodeEffectSchemaSync, validators, run) are available only when all schemas are context-free. When you use a service-requiring schema, those members are not present on the adapter — you get a TypeScript error at the call site rather than a runtime failure.

Bridges an Effect SubscriptionRef<S> to the @umpire/store contract. It runs a background fiber to track changes and interrupts it on destroy().

function fromSubscriptionRef<
S,
F extends Record<string, FieldDef>,
C extends Record<string, unknown> = Record<string, unknown>,
>(
ump: Umpire<F, C>,
ref: SubscriptionRef.SubscriptionRef<S>,
options: FromStoreOptions<S, C>,
): UmpireStore<F>
import { Effect, SubscriptionRef } from 'effect'
import { enabledWhen, umpire } from '@umpire/core'
import { fromSubscriptionRef } from '@umpire/effect'
const ump = umpire({
fields: { name: {}, email: {} },
rules: [
enabledWhen('email', (_v, c: { showEmail: boolean }) => c.showEmail),
],
})
const ref = Effect.runSync(SubscriptionRef.make({ showEmail: false }))
const store = fromSubscriptionRef(ump, ref, {
select: () => ({}),
conditions: (state) => state,
})
store.field('email').enabled // false
// Update the ref — availability recomputes automatically
await Effect.runPromise(SubscriptionRef.set(ref, { showEmail: true }))
store.field('email').enabled // true
store.destroy() // interrupts the background fiber

select and conditions follow the same contract as @umpire/store. See Selection for the full breakdown of patterns.

The returned UmpireStore surface is the same as all store adapters: field(name), fouls, getAvailability(), subscribe(listener), and destroy().

When building an API endpoint, you often need to evaluate Umpire availability rules and structural Effect schemas in one pass before writing to your database. @umpire/write handles the policy; @umpire/effect handles the schema. The two libraries combine to give you a single result object.

Consider a database provisioning endpoint where certain configuration fields only apply to specific database engines, and some fields require asynchronous verification:

import { Effect, Schema } from 'effect'
import { checkCreateAsync, composeWriteResult, runWriteValidationAdapterAsync } from '@umpire/write'
import { createEffectAdapter, toAsyncWriteValidationAdapter } from '@umpire/effect'
import { umpire, enabledWhen } from '@umpire/async'
import { CloudApi, LiveCloudApiLayer } from './services'
// 1. Define your effectful schemas
// Notice how Schema.optional() is not used here. Umpire determines whether
// a field is required or excluded entirely based on current state.
const schemas = {
engine: Schema.Literal('postgres', 'mysql', 'sqlite'),
version: Schema.String,
customParameters: Schema.Boolean,
replicaCount: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
parameterGroupId: Schema.String.pipe(
Schema.filterEffect((id) => Effect.gen(function*() {
const api = yield* CloudApi
// Async check that requires a service dependency
return yield* api.verifyParameterGroup(id)
}), { message: () => 'Parameter group not found or incompatible' })
)
}
// 2. Create the Umpire instance for your policy
const ump = umpire({
fields: {
engine: { required: true },
version: { required: true },
customParameters: { },
replicaCount: { },
parameterGroupId: { required: true } // Required, but only when enabled
},
rules: [
enabledWhen('replicaCount', (v) => v.engine !== 'sqlite', {
reason: 'SQLite does not support read replicas'
}),
enabledWhen('parameterGroupId', (v) => v.customParameters === true, {
reason: 'Parameter group is only applicable when customParameters is true'
})
]
})
// 3. Create the adapter and adapt it for async write checks
const validation = createEffectAdapter()({ schemas })
const writeValidation = toAsyncWriteValidationAdapter(validation, (effect) =>
Effect.runPromise(Effect.provide(effect, LiveCloudApiLayer))
)
// 4. In your route handler
export async function provisionDatabase(req, res) {
// First evaluate the policy...
const write = await checkCreateAsync(ump, req.body)
// Then evaluate the schema against the availability map...
const validationRun = await runWriteValidationAdapterAsync(
writeValidation,
write.availability,
write.candidate
)
// Combine both into a single result...
const result = composeWriteResult({ write, validation: validationRun })
if (!result.ok) {
// result.issues.rules contains policy failures (e.g. parameterGroupId missing when customParameters=true)
// result.issues.schema contains validation failures (e.g. parameterGroupId not found in cloud api)
return res.status(422).json({ issues: result.issues })
}
// result.debug.candidate is the fully normalized and validated payload
await db.insert(databases).values(result.debug.candidate)
return res.status(201).json({ ok: true })
}

This pattern keeps policy checks, schema validation, and persistence distinct while letting them fail together before your database throws constraints. Umpire cleanly separates the “should this field exist at all?” policy from the “is this value well-formed?” structural validation.

@umpire/effect follows Umpire’s satisfaction rules. By default, only null and undefined count as empty. So if a field does not define isEmpty, an empty string is still considered satisfied and can surface valid: false from validators immediately.

For form-style string inputs, use an explicit empty-state helper:

import { isEmptyString, umpire } from '@umpire/core'
const ump = umpire({
fields: {
email: { required: true, isEmpty: isEmptyString },
},
rules: [],
validators: createEffectAdapter()({
schemas: { email: Schema.String },
}).validators,
})

That keeps blank strings in the “not yet validateable” lane until the field is actually satisfied under your chosen emptiness rule.

@umpire/effect handles the common case — both sync and effectful. If you need finer control — custom transformations, custom error formatting, or patterns the adapter doesn’t cover — the manual approach in Composing with Validation using deriveSchema directly gives you full flexibility.

If you’re unsure which decode variant to use: reach for decodeEffectSchema in Effect workflows, and decodeEffectSchemaSync only when you explicitly need a plain synchronous result from a context-free schema. The createEffectAdapter surfaces the right methods for your schema context automatically.