Skip to content

Commit 9009b6d

Browse files
KhraksMamtsovtim-smart
authored andcommitted
Config.port and Config.branded functions have been added (#4892)
Co-authored-by: Tim <hello@timsmart.co>
1 parent 0df43f2 commit 9009b6d

File tree

5 files changed

+216
-5
lines changed

5 files changed

+216
-5
lines changed

.changeset/tasty-moose-move.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"effect": minor
3+
---
4+
5+
The `Config.port` and `Config.branded` functions have been added.
6+
7+
```ts
8+
import { Brand, Config } from "effect"
9+
10+
type DbPort = Brand.Branded<number, "DbPort">
11+
const DbPort = Brand.nominal<DbPort>()
12+
13+
const dbPort: Config.Config<DbPort> = Config.branded(
14+
Config.port("DB_PORT"),
15+
DbPort
16+
)
17+
```
18+
19+
```ts
20+
import { Brand, Config } from "effect"
21+
22+
type Port = Brand.Branded<number, "Port">
23+
const Port = Brand.refined<Port>(
24+
(num) =>
25+
!Number.isNaN(num) && Number.isInteger(num) && num >= 1 && num <= 65535,
26+
(n) => Brand.error(`Expected ${n} to be an TCP port`)
27+
)
28+
29+
const dbPort: Config.Config<Port> = Config.number("DB_PORT").pipe(
30+
Config.branded(Port)
31+
)
32+
```

packages/effect/dtslint/Config.tst.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
/* eslint-disable @typescript-eslint/no-unused-vars */
2-
import { Config, hole, pipe } from "effect"
1+
import { Brand, Config, hole, pipe } from "effect"
32
import { describe, expect, it } from "tstyche"
43

54
declare const string: Config.Config<string>
65
declare const number: Config.Config<number>
76
declare const array: Array<Config.Config<string>>
87
declare const record: Record<string, Config.Config<number>>
98

9+
type Int = Brand.Branded<number, "Int">
10+
const Int = Brand.refined<Int>(
11+
(n) => Number.isInteger(n),
12+
(n) => Brand.error(`Expected ${n} to be an integer`)
13+
)
14+
15+
type Str = Brand.Branded<string, "Str">
16+
const Str = Brand.refined<Str>(
17+
(n) => n.length > 2,
18+
(n) => Brand.error(`Expected "${n}" to be longer than 2`)
19+
)
20+
1021
describe("Config", () => {
1122
describe("all", () => {
1223
it("tuple", () => {
@@ -30,10 +41,24 @@ describe("Config", () => {
3041
})
3142
})
3243

44+
it("branded", () => {
45+
// @ts-expect-error
46+
Config.branded("NAME", Int)
47+
// @ts-expect-error
48+
Config.branded(number, Str)
49+
// @ts-expect-error
50+
number.pipe(Config.branded(Str))
51+
52+
expect(Config.branded(number, Int)).type.toBe<Config.Config<Int>>()
53+
expect(Config.branded("NAME", Str)).type.toBe<Config.Config<Str>>()
54+
expect(number.pipe(Config.branded(Int))).type.toBe<Config.Config<Int>>()
55+
expect(pipe([string, number] as const, Config.all)).type.toBe<Config.Config<[string, number]>>()
56+
})
57+
3358
it("Config.Success helper type", () => {
3459
expect(hole<Config.Config.Success<typeof string>>()).type.toBe<string>()
3560
expect(hole<Config.Config.Success<typeof number>>()).type.toBe<number>()
36-
const config = Config.all({ a: string, b: number })
37-
expect(hole<Config.Config.Success<typeof config>>()).type.toBe<{ a: string; b: number }>()
61+
const _config = Config.all({ a: string, b: number })
62+
expect(hole<Config.Config.Success<typeof _config>>()).type.toBe<{ a: string; b: number }>()
3863
})
3964
})

packages/effect/src/Config.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @since 2.0.0
33
*/
4+
import type * as Brand from "./Brand.js"
45
import type * as Chunk from "./Chunk.js"
56
import type * as ConfigError from "./ConfigError.js"
67
import type * as Duration from "./Duration.js"
@@ -129,7 +130,15 @@ export const array: <A>(config: Config<A>, name?: string) => Config<Array<A>> =
129130
export const boolean: (name?: string) => Config<boolean> = internal.boolean
130131

131132
/**
132-
* Constructs a config for a URL value.
133+
* Constructs a config for a network port [1, 65535].
134+
*
135+
* @since 3.16.0
136+
* @category constructors
137+
*/
138+
export const port: (name?: string) => Config<number> = internal.port
139+
140+
/**
141+
* Constructs a config for an URL value.
133142
*
134143
* @since 3.11.0
135144
* @category constructors
@@ -360,6 +369,26 @@ export const redacted: {
360369
<A>(config: Config<A>): Config<Redacted.Redacted<A>>
361370
} = internal.redacted
362371

372+
/**
373+
* Constructs a config for a branded value.
374+
*
375+
* @since 3.16.0
376+
* @category constructors
377+
*/
378+
export const branded: {
379+
<A, B extends Brand.Branded<A, any>>(
380+
constructor: Brand.Brand.Constructor<B>
381+
): (config: Config<A>) => Config<B>
382+
<B extends Brand.Branded<string, any>>(
383+
name: string | undefined,
384+
constructor: Brand.Brand.Constructor<B>
385+
): Config<B>
386+
<A, B extends Brand.Branded<A, any>>(
387+
config: Config<A>,
388+
constructor: Brand.Brand.Constructor<B>
389+
): Config<B>
390+
} = internal.branded
391+
363392
/**
364393
* Constructs a config for a sequence of values.
365394
*

packages/effect/src/internal/config.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type * as Brand from "../Brand.js"
12
import * as Chunk from "../Chunk.js"
23
import type * as Config from "../Config.js"
34
import * as ConfigError from "../ConfigError.js"
@@ -192,6 +193,33 @@ export const url = (name?: string): Config.Config<URL> => {
192193
return name === undefined ? config : nested(config, name)
193194
}
194195

196+
/** @internal */
197+
export const port = (name?: string): Config.Config<number> => {
198+
const config = primitive(
199+
"a network port property",
200+
(text) => {
201+
const result = Number(text)
202+
203+
if (
204+
Number.isNaN(result) ||
205+
result.toString() !== text.toString() ||
206+
!Number.isInteger(result) ||
207+
result < 1 ||
208+
result > 65535
209+
) {
210+
return Either.left(
211+
configError.InvalidData(
212+
[],
213+
`Expected a network port value but received ${text}`
214+
)
215+
)
216+
}
217+
return Either.right(result)
218+
}
219+
)
220+
return name === undefined ? config : nested(config, name)
221+
}
222+
195223
/** @internal */
196224
export const array = <A>(config: Config.Config<A>, name?: string): Config.Config<Array<A>> => {
197225
return pipe(chunk(config, name), map(Chunk.toArray))
@@ -444,6 +472,33 @@ export const redacted = <A>(
444472
return map(config, redacted_.make)
445473
}
446474

475+
/** @internal */
476+
export const branded: {
477+
<A, B extends Brand.Branded<A, any>>(
478+
constructor: Brand.Brand.Constructor<B>
479+
): (config: Config.Config<A>) => Config.Config<B>
480+
<B extends Brand.Branded<string, any>>(
481+
name: string | undefined,
482+
constructor: Brand.Brand.Constructor<B>
483+
): Config.Config<B>
484+
<A, B extends Brand.Branded<A, any>>(
485+
config: Config.Config<A>,
486+
constructor: Brand.Brand.Constructor<B>
487+
): Config.Config<B>
488+
} = dual(2, <A, B extends Brand.Brand.Constructor<any>>(
489+
nameOrConfig: Config.Config<NoInfer<A>> | string | undefined,
490+
constructor: B
491+
) => {
492+
const config: Config.Config<string | A> = isConfig(nameOrConfig) ? nameOrConfig : string(nameOrConfig)
493+
494+
return mapOrFail(config, (a) =>
495+
constructor.either(a).pipe(
496+
Either.mapLeft((brandErrors) =>
497+
configError.InvalidData([], brandErrors.map((brandError) => brandError.message).join("\n"))
498+
)
499+
))
500+
})
501+
447502
/** @internal */
448503
export const hashSet = <A>(config: Config.Config<A>, name?: string): Config.Config<HashSet.HashSet<A>> => {
449504
const newConfig = map(chunk(config), HashSet.fromIterable)

packages/effect/test/Config.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it } from "@effect/vitest"
22
import { assertFailure, assertSuccess, assertTrue, deepStrictEqual, strictEqual } from "@effect/vitest/utils"
33
import {
4+
Brand,
45
Cause,
56
Chunk,
67
Config,
@@ -17,6 +18,12 @@ import {
1718
Secret
1819
} from "effect"
1920

21+
type Str = Brand.Branded<string, "Str">
22+
const Str = Brand.refined<Str>(
23+
(n) => n.length > 2,
24+
(n) => Brand.error(`Brand: Expected ${n} to be longer than 2`)
25+
)
26+
2027
const assertConfigError = <A>(
2128
config: Config.Config<A>,
2229
map: ReadonlyArray<readonly [string, string]>,
@@ -90,6 +97,69 @@ describe("Config", () => {
9097
})
9198
})
9299

100+
describe("port", () => {
101+
it("name != undefined", () => {
102+
const config = Config.port("WEBSITE_PORT")
103+
104+
assertConfig(
105+
config,
106+
[["WEBSITE_PORT", "123"]],
107+
123
108+
)
109+
assertConfigError(
110+
config,
111+
[["WEBSITE_PORT", "abra-kadabra"]],
112+
ConfigError.InvalidData(["WEBSITE_PORT"], "Expected a network port value but received abra-kadabra")
113+
)
114+
assertConfigError(
115+
config,
116+
[],
117+
ConfigError.MissingData(["WEBSITE_PORT"], "Expected WEBSITE_PORT to exist in the provided map")
118+
)
119+
})
120+
})
121+
122+
describe("branded", () => {
123+
it("name != undefined", () => {
124+
const config = Config.branded(Config.string("STR"), Str)
125+
126+
assertConfig(
127+
config,
128+
[["STR", "123"]],
129+
Str("123")
130+
)
131+
assertConfigError(
132+
config,
133+
[["STR", "1"]],
134+
ConfigError.InvalidData(["STR"], "Brand: Expected 1 to be longer than 2")
135+
)
136+
assertConfigError(
137+
config,
138+
[],
139+
ConfigError.MissingData(["STR"], "Expected STR to exist in the provided map")
140+
)
141+
})
142+
it("name != undefined from name", () => {
143+
const config = Config.branded("STR", Str)
144+
145+
assertConfig(
146+
config,
147+
[["STR", "123"]],
148+
Str("123")
149+
)
150+
assertConfigError(
151+
config,
152+
[["STR", "1"]],
153+
ConfigError.InvalidData(["STR"], "Brand: Expected 1 to be longer than 2")
154+
)
155+
assertConfigError(
156+
config,
157+
[],
158+
ConfigError.MissingData(["STR"], "Expected STR to exist in the provided map")
159+
)
160+
})
161+
})
162+
93163
describe("nonEmptyString", () => {
94164
it("name = undefined", () => {
95165
const config = Config.array(Config.nonEmptyString(), "ITEMS")

0 commit comments

Comments
 (0)