Services
Declare contextual dependencies with exact TypeScript inference.
A Service is three things at once:
- the instance contract your application programs use;
- the runtime constructor token a backend resolves;
- 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.
Minifiers, bundlers and duplicate package copies can change or duplicate a constructor name. The
literal serviceTag is intentional, inspectable and stable across those boundaries.
An empty tag cannot communicate ownership or identity and makes collision diagnostics ambiguous.
Service rejects it at declaration time.
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() === 150setTime 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.offor 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.