diff --git a/.changeset/curvy-colts-clap.md b/.changeset/curvy-colts-clap.md new file mode 100644 index 00000000000..95819fefb03 --- /dev/null +++ b/.changeset/curvy-colts-clap.md @@ -0,0 +1,15 @@ +--- +"effect": patch +--- + +Add Config.duration + +This can be used to parse Duration's from environment variables: + +```ts +import { Config, Effect } from "effect" + +Config.duration("CACHE_TTL").pipe( + Effect.andThen((duration) => ...) +) +``` diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index 80804e4b1c4..955232fa210 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -3,6 +3,7 @@ */ import type * as Chunk from "./Chunk.js" import type * as ConfigError from "./ConfigError.js" +import type * as Duration from "./Duration.js" import type * as Effect from "./Effect.js" import type * as Either from "./Either.js" import type { LazyArg } from "./Function.js" @@ -180,6 +181,14 @@ export const literal: >(...literals */ export const logLevel: (name?: string) => Config = internal.logLevel +/** + * Constructs a config for a duration value. + * + * @since 2.5.0 + * @category constructors + */ +export const duration: (name?: string) => Config = internal.duration + /** * This function returns `true` if the specified value is an `Config` value, * `false` otherwise. diff --git a/packages/effect/src/Duration.ts b/packages/effect/src/Duration.ts index ead36133781..96e0ad22c18 100644 --- a/packages/effect/src/Duration.ts +++ b/packages/effect/src/Duration.ts @@ -11,7 +11,7 @@ import * as Option from "./Option.js" import * as order from "./Order.js" import type { Pipeable } from "./Pipeable.js" import { pipeArguments } from "./Pipeable.js" -import { hasProperty, isBigInt, isNumber } from "./Predicate.js" +import { hasProperty, isBigInt, isNumber, isString } from "./Predicate.js" const TypeId: unique symbol = Symbol.for("effect/Duration") @@ -94,7 +94,7 @@ export const decode = (input: DurationInput): Duration => { if (input.length === 2 && isNumber(input[0]) && isNumber(input[1])) { return nanos(BigInt(input[0]) * bigint1e9 + BigInt(input[1])) } - } else { + } else if (isString(input)) { DURATION_REGEX.lastIndex = 0 // Reset the lastIndex before each use const match = DURATION_REGEX.exec(input) if (match) { @@ -131,6 +131,11 @@ export const decode = (input: DurationInput): Duration => { throw new Error("Invalid duration input") } +/** + * @since 2.5.0 + */ +export const decodeUnknown: (u: unknown) => Option.Option = Option.liftThrowable(decode) as any + const zeroValue: DurationValue = { _tag: "Millis", millis: 0 } const infinityValue: DurationValue = { _tag: "Infinity" } diff --git a/packages/effect/src/internal/config.ts b/packages/effect/src/internal/config.ts index 26f73182831..98d9f43fefa 100644 --- a/packages/effect/src/internal/config.ts +++ b/packages/effect/src/internal/config.ts @@ -1,6 +1,7 @@ import * as Chunk from "../Chunk.js" import type * as Config from "../Config.js" import * as ConfigError from "../ConfigError.js" +import * as Duration from "../Duration.js" import * as Either from "../Either.js" import type { LazyArg } from "../Function.js" import { constTrue, dual, pipe } from "../Function.js" @@ -288,6 +289,15 @@ export const logLevel = (name?: string): Config.Config => { return name === undefined ? config : nested(config, name) } +/** @internal */ +export const duration = (name?: string): Config.Config => { + const config = mapOrFail(string(), (value) => { + const duration = Duration.decodeUnknown(value) + return Either.fromOption(duration, () => configError.InvalidData([], `Expected a duration but received ${value}`)) + }) + return name === undefined ? config : nested(config, name) +} + /** @internal */ export const map = dual< (f: (a: A) => B) => (self: Config.Config) => Config.Config, diff --git a/packages/effect/test/Config.test.ts b/packages/effect/test/Config.test.ts index bbbb0edfa03..6fc2849c055 100644 --- a/packages/effect/test/Config.test.ts +++ b/packages/effect/test/Config.test.ts @@ -2,6 +2,7 @@ import * as Chunk from "effect/Chunk" import * as Config from "effect/Config" import * as ConfigError from "effect/ConfigError" import * as ConfigProvider from "effect/ConfigProvider" +import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Equal from "effect/Equal" import * as Exit from "effect/Exit" @@ -196,6 +197,26 @@ describe("Config", () => { }) }) + describe("duration", () => { + it("name = undefined", () => { + const config = Config.duration() + assertSuccess(config, [["", "10 seconds"]], Duration.decode("10 seconds")) + + assertFailure(config, [["", "-"]], ConfigError.InvalidData([], "Expected a duration but received -")) + }) + + it("name != undefined", () => { + const config = Config.duration("DURATION") + assertSuccess(config, [["DURATION", "10 seconds"]], Duration.decode("10 seconds")) + + assertFailure( + config, + [["DURATION", "-"]], + ConfigError.InvalidData(["DURATION"], "Expected a duration but received -") + ) + }) + }) + describe("validate", () => { it("should preserve the original path", () => { const flat = Config.number("NUMBER").pipe( diff --git a/packages/effect/test/Duration.test.ts b/packages/effect/test/Duration.test.ts index 7d3be1a5f23..7de8e3daf81 100644 --- a/packages/effect/test/Duration.test.ts +++ b/packages/effect/test/Duration.test.ts @@ -42,6 +42,44 @@ describe("Duration", () => { expect(Duration.decode([-500, 123456789])).toEqual(Duration.zero) expect(() => Duration.decode("1.5 secs" as any)).toThrowError(new Error("Invalid duration input")) + expect(() => Duration.decode(true as any)).toThrowError(new Error("Invalid duration input")) + expect(() => Duration.decode({} as any)).toThrowError(new Error("Invalid duration input")) + }) + + it("decodeUnknown", () => { + const millis100 = Duration.millis(100) + expect(Duration.decodeUnknown(millis100)).toEqual(Option.some(millis100)) + + expect(Duration.decodeUnknown(100)).toEqual(Option.some(millis100)) + + expect(Duration.decodeUnknown(10n)).toEqual(Option.some(Duration.nanos(10n))) + + expect(Duration.decodeUnknown("1 nano")).toEqual(Option.some(Duration.nanos(1n))) + expect(Duration.decodeUnknown("10 nanos")).toEqual(Option.some(Duration.nanos(10n))) + expect(Duration.decodeUnknown("1 micro")).toEqual(Option.some(Duration.micros(1n))) + expect(Duration.decodeUnknown("10 micros")).toEqual(Option.some(Duration.micros(10n))) + expect(Duration.decodeUnknown("1 milli")).toEqual(Option.some(Duration.millis(1))) + expect(Duration.decodeUnknown("10 millis")).toEqual(Option.some(Duration.millis(10))) + expect(Duration.decodeUnknown("1 second")).toEqual(Option.some(Duration.seconds(1))) + expect(Duration.decodeUnknown("10 seconds")).toEqual(Option.some(Duration.seconds(10))) + expect(Duration.decodeUnknown("1 minute")).toEqual(Option.some(Duration.minutes(1))) + expect(Duration.decodeUnknown("10 minutes")).toEqual(Option.some(Duration.minutes(10))) + expect(Duration.decodeUnknown("1 hour")).toEqual(Option.some(Duration.hours(1))) + expect(Duration.decodeUnknown("10 hours")).toEqual(Option.some(Duration.hours(10))) + expect(Duration.decodeUnknown("1 day")).toEqual(Option.some(Duration.days(1))) + expect(Duration.decodeUnknown("10 days")).toEqual(Option.some(Duration.days(10))) + expect(Duration.decodeUnknown("1 week")).toEqual(Option.some(Duration.weeks(1))) + expect(Duration.decodeUnknown("10 weeks")).toEqual(Option.some(Duration.weeks(10))) + + expect(Duration.decodeUnknown("1.5 seconds")).toEqual(Option.some(Duration.seconds(1.5))) + expect(Duration.decodeUnknown("-1.5 seconds")).toEqual(Option.some(Duration.zero)) + + expect(Duration.decodeUnknown([500, 123456789])).toEqual(Option.some(Duration.nanos(500123456789n))) + expect(Duration.decodeUnknown([-500, 123456789])).toEqual(Option.some(Duration.zero)) + + expect(Duration.decodeUnknown("1.5 secs")).toEqual(Option.none()) + expect(Duration.decodeUnknown(true)).toEqual(Option.none()) + expect(Duration.decodeUnknown({})).toEqual(Option.none()) }) it("Order", () => {