diff --git a/.changeset/service-deps-layer-typing.md b/.changeset/service-deps-layer-typing.md new file mode 100644 index 000000000..12e32ec6b --- /dev/null +++ b/.changeset/service-deps-layer-typing.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix Service helper typing so dependency layers eliminate make requirements, add docgen @since for Service exports, and cover dependency elimination with dtslint checks. diff --git a/packages/effect/dtslint/Service.tst.ts b/packages/effect/dtslint/Service.tst.ts new file mode 100644 index 000000000..88a7f7bff --- /dev/null +++ b/packages/effect/dtslint/Service.tst.ts @@ -0,0 +1,35 @@ +import type { Layer } from "effect" +import { Effect, Service } from "effect" +import { describe, expect, it } from "tstyche" + +describe("Service public typing", () => { + it("layer removes requirements satisfied by dependencies", () => { + class Config extends Service()("Config", { + make: (prefix: string) => Effect.succeed({ prefix }) + }) {} + + class Logger extends Service()("Logger", { + make: Effect.gen(function*() { + const cfg = yield* Config + return { log: (msg: string) => Effect.succeed(`${cfg.prefix}:${msg}`) } + }), + dependencies: [Config.layer("cfg")] + }) {} + + expect(Logger.layer).type.toBe>() + expect(Logger.layerWithoutDependencies).type.toBe>() + }) + + it("factory make keeps constructor parameters on layer", () => { + class Http extends Service()("Http", { + make: (base: string, timeout: number) => + Effect.succeed({ + base, + timeout, + get: (path: string) => Effect.succeed(`${base}${path}`) + }) + }) {} + + expect(Http.layer).type.toBe<(base: string, timeout: number) => Layer.Layer>() + }) +}) diff --git a/packages/effect/src/Service.ts b/packages/effect/src/Service.ts new file mode 100644 index 000000000..0cb7bb5a8 --- /dev/null +++ b/packages/effect/src/Service.ts @@ -0,0 +1,298 @@ +import type * as EffectTypes from "./Effect.ts" +import * as Effect from "./Effect.ts" +import { isEffect } from "./internal/core.ts" +import * as Layer from "./Layer.ts" +import type { Scope } from "./Scope.ts" +import * as ServiceMap from "./ServiceMap.ts" +import type * as Types from "./types/Types.ts" + +/** + * Extracts the Effect type from a make function or Effect value. + * + * @since 4.0.0 + * @category Internal + */ +type MakeEffect = Make extends (...args: Array) => EffectTypes.Effect ? ReturnType + : Make + +/** + * Extracts the argument types from a make function. + * + * @since 4.0.0 + * @category Internal + */ +type MakeArgs = Make extends (...args: infer Args) => EffectTypes.Effect ? Args : never + +/** + * Extracts the combined service requirements from dependency layers. + * + * @since 4.0.0 + * @category Internal + */ +type DepsContext> | undefined> = Deps extends + ReadonlyArray> ? Layer.Services + : never + +/** + * Lifts a value, Promise, or Effect into an Effect type. + * + * @since 4.0.0 + * @category Internal + */ +type LiftToEffect = X extends EffectTypes.Effect ? EffectTypes.Effect + : X extends Promise ? EffectTypes.Effect + : EffectTypes.Effect + +/** + * Layer type without dependencies - requires what make effect requires (excluding Scope). + * + * @since 4.0.0 + * @category Internal + */ +type LayerShapeNoDeps = Layer.Layer< + Self, + EffectTypes.Error, + Exclude, Scope> +> + +/** + * Layer type with dependencies - requires only what dependency layers require. + * + * @since 4.0.0 + * @category Internal + */ +type LayerShapeWithDeps = Layer.Layer, DepsReq> + +/** + * Converts an optional dependency array to a non-empty tuple type. + * + * @since 4.0.0 + * @category Internal + */ +type NonEmptyDeps> | undefined> = Deps extends + ReadonlyArray ? readonly [L, ...Array] : never + +/** + * Generates the layer type from make function, handling both factory and value cases. + * + * @since 4.0.0 + * @category Internal + */ +type LayerFromMake> | undefined> = Deps extends + undefined ? ([MakeArgs] extends [never] ? LayerShapeNoDeps> + : (...args: MakeArgs) => LayerShapeNoDeps>) + : ([MakeArgs] extends [never] ? LayerShapeWithDeps, DepsContext> + : (...args: MakeArgs) => LayerShapeWithDeps, DepsContext>) + +/** + * Layer type ignoring dependencies - always requires what make effect requires. + * + * @since 4.0.0 + * @category Internal + */ +type LayerWithoutDepsFromMake = [MakeArgs] extends [never] ? LayerShapeNoDeps> + : (...args: MakeArgs) => LayerShapeNoDeps> + +// Type guard to check if a value is a Promise. +const isPromise = (u: unknown): u is Promise => + typeof u === "object" && u !== null && "then" in u && typeof (u as any).then === "function" + +// Builds the `use` helper for a service, allowing callback-based access. +const buildUse = (service: any) => { + return (f: (svc: any) => X): EffectTypes.Effect => + Effect.gen(function*() { + const svc = yield* service + const result = f(svc) + if (isEffect(result)) { + return yield* result + } + if (isPromise(result)) { + return yield* Effect.promise(() => result) + } + return result + }) +} + +// Builds the `layer` or `layerWithoutDependencies`, handling factories and dependency provision. +const buildLayer = ( + service: any, + make: EffectTypes.Effect | ((...args: Array) => EffectTypes.Effect), + dependencies?: ReadonlyArray> +) => { + const isFactory = typeof make === "function" + const depsLayer = dependencies && dependencies.length > 0 + ? Layer.mergeAll(...(dependencies as NonEmptyDeps)) + : undefined + + const base = (...args: Array) => { + const eff = isFactory ? (make as any)(...args) : make + return Layer.effect(service, eff) + } + + return depsLayer + ? isFactory + ? (...args: Array) => Layer.provide(base(...args), depsLayer) + : Layer.provide(base(), depsLayer) + : isFactory + ? (...args: Array) => base(...args) + : base() +} + +/** + * Extended ServiceClass with layer helpers for services with `make`. + * + * Provides: + * - `make`: The effect or factory function to create the service + * - `use`: Callback-based service access + * - `layer`: Layer constructor respecting dependencies + * - `layerWithoutDependencies`: Layer constructor ignoring dependencies (only when deps provided) + * + * @since 4.0.0 + * @category Models + */ +export type ServiceWithMake< + Self, + Id extends string, + Shape, + Make extends EffectTypes.Effect | ((...args: any) => EffectTypes.Effect), + Deps extends ReadonlyArray> | undefined +> = ServiceMap.ServiceClass & { + readonly make: Make + readonly use: (f: (svc: Shape) => X) => LiftToEffect + readonly layer: LayerFromMake + readonly layerWithoutDependencies: Deps extends undefined ? never : LayerWithoutDepsFromMake +} + +/** + * Creates a service with layer helpers when `make` is provided. + * + * @example + * ```ts + * import { Service, Effect } from "effect" + * + * class Logger extends Service()("Logger", { + * make: Effect.sync(() => ({ log: (msg: string) => console.log(msg) })) + * }) {} + * + * // Use Logger.layer, Logger.use, etc. + * ``` + * + * @since 4.0.0 + * @category Constructors + */ +export type ServiceConstructor = { + // Plain tag (no make) + (key: string): ServiceMap.Service + // Curried with explicit Shape; make optional + (): < + const Identifier extends string, + E, + R = Types.unassigned, + Args extends ReadonlyArray = never, + Deps extends ReadonlyArray> | undefined = undefined + >( + id: Identifier, + options?: { + readonly make?: ((...args: Args) => EffectTypes.Effect) | EffectTypes.Effect | undefined + readonly dependencies?: Deps + } | undefined + ) => [Types.unassigned] extends [R] ? ServiceMap.ServiceClass + : ServiceWithMake< + Self, + Identifier, + Shape, + [Args] extends [never] ? EffectTypes.Effect : (...args: Args) => EffectTypes.Effect, + Deps + > + // Curried with inferred Shape; make required + (): < + const Identifier extends string, + Make extends EffectTypes.Effect | ((...args: any) => EffectTypes.Effect), + Deps extends ReadonlyArray> | undefined = undefined + >( + id: Identifier, + options: { + readonly make: Make + readonly dependencies?: Deps + } + ) => ServiceWithMake< + Self, + Identifier, + Make extends + | EffectTypes.Effect + | ((...args: infer _Args) => EffectTypes.Effect) ? _A + : never, + Make, + Deps + > +} + +const ServiceImpl = (...args: Array) => { + if (args.length === 0) { + const baseService = ServiceMap.Service() + + return function(key: string, options?: { + readonly make?: any + readonly dependencies?: ReadonlyArray> + }) { + const service = options?.make + ? baseService(key, { make: options.make }) + : baseService(key) + + if (options?.make) { + const deps = options.dependencies + type Self = typeof service + type Make = typeof options.make + type Deps = typeof deps + + const svc = service as Types.Mutable< + & ServiceMap.ServiceClass + & Partial> + > + + svc.layer = buildLayer(svc, options.make, deps) as LayerFromMake + if (deps && deps.length > 0) { + svc.layerWithoutDependencies = buildLayer(svc, options.make) as LayerWithoutDepsFromMake + } + svc.use = buildUse(svc) as ServiceWithMake["use"] + } + + return service + } + } + + return (ServiceMap.Service as (...fnArgs: Array) => any)(...args) +} + +/** + * Layer-aware service constructor. Use this when providing `make` to get + * automatic `layer`, `layerWithoutDependencies`, and `use` helpers. + * + * @example + * ```ts + * import { Service, Effect } from "effect" + * + * class Logger extends Service()("Logger", { + * make: Effect.sync(() => ({ log: (msg: string) => console.log(msg) })) + * }) {} + * + * // Use Logger.layer, Logger.use, etc. + * ``` + * + * @since 4.0.0 + */ +export const Service = ServiceImpl as ServiceConstructor + +/** + * Alias to the underlying service tag type. + * + * @since 4.0.0 + */ +export type ServiceTag = ServiceMap.Service + +/** + * Alias to the underlying service class type. + * + * @since 4.0.0 + */ +export type ServiceClass = ServiceMap.ServiceClass diff --git a/packages/effect/src/index.ts b/packages/effect/src/index.ts index 9cb7885ef..8824cac9a 100644 --- a/packages/effect/src/index.ts +++ b/packages/effect/src/index.ts @@ -1298,6 +1298,17 @@ export * as ScopedRef from "./ScopedRef.ts" * * @since 4.0.0 */ +export { + /** + * Layer-aware service constructor with automatic helpers. + * + * @since 4.0.0 + */ + Service +} from "./Service.ts" +/** + * @since 4.0.0 + */ export * as ServiceMap from "./ServiceMap.ts" /** diff --git a/packages/effect/test/Service.layer.test.ts b/packages/effect/test/Service.layer.test.ts new file mode 100644 index 000000000..1dd042d04 --- /dev/null +++ b/packages/effect/test/Service.layer.test.ts @@ -0,0 +1,116 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Layer, Service } from "effect" + +describe("Service layer helpers", () => { + it.effect("provides service via layer", () => + Effect.gen(function*() { + class Logger extends Service()("Logger", { + make: Effect.sync(() => ({ + info: (msg: string) => Effect.sync(() => msg) + })) + }) {} + + const program = Effect.gen(function*() { + const logger = yield* Logger + return yield* logger.info("hello") + }).pipe(Effect.provide(Logger.layer)) + + assert.strictEqual(yield* program, "hello") + })) + + it.effect("supports deps and layerWithoutDependencies", () => + Effect.gen(function*() { + class Config extends Service()("Config", { + make: Effect.succeed({ prefix: "[cfg]" }) + }) {} + + class Logger extends Service()("Logger", { + make: Effect.gen(function*() { + const cfg = yield* Config + return { + info: (msg: string) => Effect.sync(() => `${cfg.prefix} ${msg}`) + } + }), + dependencies: [Config.layer] + }) {} + + // Logger.layer should NOT require Config (dependencies provide it) + const program = Effect.gen(function*() { + const logger = yield* Logger + return yield* logger.info("ok") + }).pipe(Effect.provide(Logger.layer)) + + assert.strictEqual(yield* program, "[cfg] ok") + + // layerWithoutDependencies requires Config (verified at type level in Service.tst.ts) + // We can't test this at runtime because TypeScript prevents using it without Config + // Providing Config.layer to verify it works when Config is available: + const programWithConfig = Effect.gen(function*() { + const logger = yield* Logger + return yield* logger.info("ok") + }).pipe( + Effect.provide(Layer.provide(Logger.layerWithoutDependencies, Config.layer)) + ) + assert.strictEqual(yield* programWithConfig, "[cfg] ok") + })) + + it.effect("factory make forwards args to layer", () => + Effect.gen(function*() { + class Http extends Service()("Http", { + make: (base: string) => + Effect.sync(() => ({ + url: base, + get: (path: string) => Effect.sync(() => `${base}${path}`) + })) + }) {} + + const program = Effect.gen(function*() { + const http = yield* Http + return yield* http.get("/ping") + }).pipe(Effect.provide(Http.layer("https://api"))) + + assert.strictEqual(yield* program, "https://api/ping") + })) + + it.effect("use lifts promise/value/effect", () => + Effect.gen(function*() { + class Foo extends Service()("Foo", { + make: Effect.sync(() => ({ + value: 1, + eff: () => Effect.succeed(2), + prom: () => Promise.resolve(3) + })) + }) {} + + const effResult = yield* Foo.use((f) => f.eff()).pipe(Effect.provide(Foo.layer)) + const promResult = yield* Foo.use((f) => f.prom()).pipe(Effect.provide(Foo.layer)) + const valResult = yield* Foo.use((f) => f.value).pipe(Effect.provide(Foo.layer)) + + assert.strictEqual(effResult, 2) + assert.strictEqual(promResult, 3) + assert.strictEqual(valResult, 1) + })) + + it.effect("scoped make cleans up via Layer.effect", () => + Effect.gen(function*() { + const logs: Array = [] + + class Scoped extends Service()("Scoped", { + make: Effect.acquireRelease( + Effect.sync(() => ({ tag: "svc" })), + () => + Effect.sync(() => { + logs.push("finalized") + }) + ) + }) {} + + const program = Effect.gen(function*() { + const svc = yield* Scoped + logs.push(svc.tag) + }).pipe(Effect.scoped, Effect.provide(Scoped.layer)) + + yield* program + assert.deepStrictEqual(logs, ["svc", "finalized"]) + })) +})