better-effect
Core concepts

Services

Declare contextual dependencies with exact TypeScript inference.

A Service is three things at once:

  1. the instance contract your application programs use;
  2. the runtime constructor token a backend resolves;
  3. the yieldable value that can be requested from Effect.gen.

That combination means you do not maintain a second string key or a manually duplicated dependency list.

Declare a Service

Use the explicit self type and a non-empty literal tag:

import { Service } from 'better-effect'

class Database extends Service<Database>()('Database') {
  constructor(private readonly url: string) {
    super()
  }

  query(sql: string) {
    // return a Result or a Promise<Result> in a real application
    return `${this.url}: ${sql}`
  }
}

The first call anchors the exact instance type. The second call captures the literal serviceTag, which is the stable logical identity used by Layers and backends. The constructor name is diagnostic only; identity comparisons use serviceTag.

Request a Service with yield*

import { Effect } from 'better-effect'
import { Result } from 'better-result'

const readUser = Effect.fn(async function* () {
  const database = yield* Database

  const value = await database.query('select * from users')

  return Result.ok(value)
})

database is inferred as exactly Database, not unknown, not a structural subset, and not the constructor type. The yielded token also adds Database to Effect.Requirements<typeof readUser>.

type Requirements = Effect.Requirements<typeof readUser>
// Database

type DatabaseInstance = Service.Instance<typeof Database>
type DatabaseTag = Service.Tag<Database>
// 'Database'
type DatabaseToken = Service.TokenOf<Database>
// Service.Token<'Database', Database>

These associated helpers are type-only aliases: they disappear at runtime and do not wrap or replace the better-result value. Every Service instance has a required declaration-only Service.Identity<Tag> marker, but no identity property is emitted at runtime. Service.TokenOf<Database> recovers the canonical constructor contract while Layers retain the exact registering constructor separately. Service.Requirements<T> derives method-level instance dependencies from a Service contract's Effect-returning methods, so Layers can include those requirements in their completeness checks. The prefixed EffectRequirements and ServiceRequirements spellings remain public compatibility aliases.

Structural implementations with Service.of

Some Services are contracts rather than classes with meaningful construction. Declare the contract's members and use Service.of to type-check a plain object:

class Authorization extends Service<Authorization>()('Authorization') {
  declare readonly authorize: (token: string) => Promise<boolean>
}

const authorization = Authorization.of({
  authorize: async (token) => token.length > 0
})

Service.Contract<Authorization> is the marker-free shape accepted here and by Layer.make, Layer.succeed, Layer.scoped, Layer.gen and Layer.scopedGen. Authorization.of returns the same object typed as Authorization; it does not call a constructor or emit the identity marker, and the object is not an instanceof Authorization. Choose new Authorization(...) when private fields, constructor invariants or prototype behavior matter.

Tags are identities

Tags must be non-empty string literals. Use a namespaced tag when a contract is intended to be shared between packages:

class Logger extends Service<Logger>()('@acme/Logger') {
  info(message: string) {}
}

Two classes with the same methods but different tags are different Services. Two classes with the same tag are expected to represent the same logical contract; Layers and backends check their compatibility before returning an instance.

Service methods can carry requirements

Requirements are inferred not only from a generator that yields a Service, but also from Service methods that return Effects. This lets a Layer provider carry the dependencies needed by its implementation:

class UserRepository extends Service<UserRepository>()('UserRepository') {
  findById(id: string) {
    return Effect.gen(async function* () {
      const database = yield* Database
      const user = yield* Result.await(database.findById(id))

      return Result.ok(user)
    })
  }
}

const UserRepositoryLive = Layer.make(UserRepository)
// Layer<UserRepository, Database>
// Database remains external while the provider is acquired.

Service.Requirements<UserRepository> extracts those method-level instance requirements for Layer completeness checks.

Service access at runtime

Application code should use yield* Service inside an Effect program. The Runtime supplies the active resolver and restores its context after execution; there is no global lookup to configure in normal application code:

const readDatabase = Effect.fn(async function* () {
  const database = yield* Database
  return Result.ok(await database.query('select 1'))
})

const result = await runtime.run(readDatabase)

Optional standard services

Host-backed cross-cutting services are available from the optional better-effect/standard-services entrypoint. They are ordinary Services, so applications must compose a Layer and can replace each implementation in a test:

import { Effect, Runtime } from 'better-effect'
import { Clock, ClockTest } from 'better-effect/standard-services'

const clockLayer = ClockTest.layer(new Date('2025-01-01T00:00:00.000Z'))
const runtime = await Runtime.make(clockLayer)

const now = await runtime.run(() =>
  Effect.gen(async function* () {
    const clock = yield* Clock
    return Result.ok(clock.now())
  })
)
await runtime.dispose()

The entrypoint also includes Random/RandomSeeded, Logger/LoggerTest, CurrentRequest, the compatible CurrentAbortSignal bridge, and the schema-aware Config Service. Importing it never installs a hidden global provider.

Schema-aware configuration

Config.withSchema(schema) creates a Service token whose get method is typed from the schema's decoded output. Its Layer validates the source during acquisition, so each selected value keeps the type declared by the schema:

import { Result } from 'better-result'
import { Effect, Runtime } from 'better-effect'
import { Config } from 'better-effect/standard-services'

const EnvSchema = {
  '~standard': {
    version: 1,
    vendor: 'application',
    types: {} as {
      input: {
        readonly PORT?: string
        readonly APP_NAME?: string
      }
      output: {
        readonly port: number
        readonly name: string
      }
    },
    validate: (raw: unknown) => {
      const env = raw as Record<string, string | undefined>
      const port = Number(env.PORT)

      return Number.isInteger(port) && env.APP_NAME !== undefined
        ? { value: { port, name: env.APP_NAME } }
        : { issues: [{ message: 'PORT and APP_NAME are required' }] }
    }
  }
}

const AppConfig = Config.withSchema(EnvSchema)
const AppConfigLive = AppConfig.layerFromEnv({
  envSource: { PORT: '3000', APP_NAME: 'api' }
})
const runtime = await Runtime.make(AppConfigLive)

const result = await runtime.run(
  Effect.fn(async function* () {
    const config = yield* AppConfig

    const port = config.get('port') // number
    const name = config.get('name') // string
    // config.get('PORT') // TypeScript error: not a decoded output key

    return Result.ok({ port, name })
  })
)

await runtime.dispose()

The returned values are the schema output, not raw environment strings. An optional output property also remains optional in the return type. Use Config.fromEnv({ schema }) when the complete decoded object is preferable, or Config.schema(schema) with Config.layer(...) when multiple descriptors need to share an explicitly replaceable raw source.

Cancellable sleeps and deterministic time

Clock.sleep accepts an optional AbortSignal while preserving the original clock.sleep(milliseconds) form. Invalid delays still throw synchronously. An already-aborted signal does not install a timer; an active signal cancels the sleep, removes its listener and clears its timer. The signal's reason is rejected unchanged. When no reason is available, the rejection is an AbortError-named DOMException.

Pass the signal through polling, retry delays and expiration checks:

const poll = async (clock: Clock, signal: AbortSignal) => {
  while (true) {
    const status = await readStatus()
    if (status.ready) return status

    await clock.sleep(1_000, { signal })
  }
}

const retry = async <A>(operation: () => Promise<A>, clock: Clock, signal: AbortSignal) => {
  for (let attempt = 0; ; attempt += 1) {
    try {
      return await operation()
    } catch (error) {
      if (attempt === 2) throw error
      await clock.sleep(100 * 2 ** attempt, { signal })
    }
  }
}

const waitUntilExpired = async (clock: Clock, expiresAt: number, signal: AbortSignal) => {
  while (true) {
    const remaining = expiresAt - clock.now().getTime()
    if (remaining <= 0) return

    await clock.sleep(Math.min(remaining, 1_000), { signal })
  }
}

ClockTest keeps sleeps ordered by ascending absolute deadline and resolves same-deadline sleeps FIFO. pendingSleeps is a readonly count, advanceToNext() moves synchronously to the next deadline and returns false when there is nothing to advance, and runAll({ maxSteps }) advances repeatedly while awaiting one microtask checkpoint between steps. This lets resumed code schedule its next wait without virtualizing the event loop:

const clock = new ClockTest(0)
const task = (async () => {
  await clock.sleep(100)
  await clock.sleep(50)
})()

await clock.runAll({ maxSteps: 10 })
await task
// clock.now().getTime() === 150

setTime accepts backward moves. Existing sleeps retain their absolute deadlines and only resolve when the clock reaches them; forward moves resolve all sleeps due at the new time. ClockTest does not replace Date, global setTimeout, or the JavaScript microtask queue.

Service checklist

  • Give every Service a literal, non-empty tag.
  • Use the explicit self type: Service<Self>()('Tag').
  • Yield the constructor, never a string key.
  • Keep behavior in the Service and construction in its Layer.
  • Use Service.of for structural test doubles and lightweight contracts.
  • Let method return types expose dependencies through Effect.gen.

For a native Kysely database exposed as a yieldable Service, see the optional Kysely integration.

On this page