Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lucky-foxes-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": minor
---

Add ability to parse literals in the program to brands
50 changes: 38 additions & 12 deletions packages/effect/src/Brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,20 @@ const TypeId = "~effect/Brand"
* @category models
* @since 2.0.0
*/
export interface Brand<in out Keys extends string> {
export interface Brand<
in out Keys extends string,
LiteralParser extends { value: unknown; result: unknown } = IdentityLiteralParser
> {
readonly [TypeId]: {
readonly [K in Keys]: Keys
readonly [K in Keys]: [K, LiteralParser]
}
}

export interface IdentityLiteralParser {
value: unknown
result: this["value"]
}

/**
* A constructor for a branded type that provides validation and safe
* construction methods.
Expand All @@ -56,12 +64,16 @@ export interface Brand<in out Keys extends string> {
* @category models
* @since 2.0.0
*/
export interface Constructor<in out B extends Brand<any>> {
export interface Constructor<in out B extends Brand<any, any>> {
/**
* Constructs a branded type from a value of type `Unbranded<B>`, throwing an
* error if the provided value is not valid.
*/
(unbranded: Brand.Unbranded<B>): B
/**
* Constructs a branded type from a literal
*/
literal<T extends Brand.Unbranded<B> & Brand.Parse<B, T>>(value: T): B
/**
* Constructs a branded type from a value of type `Unbranded<B>`, returning
* `Some<B>` if the provided value is valid, `None` otherwise.
Expand Down Expand Up @@ -164,24 +176,24 @@ export declare namespace Brand {
* @category utility types
* @since 2.0.0
*/
export type Unbranded<B extends Brand<any>> = B extends infer U & Brands<B> ? U : B
export type Unbranded<B extends Brand<any, any>> = B extends infer U & Brands<B> ? U : B

/**
* A utility type to extract the keys of a branded type.
*
* @category utility types
* @since 4.0.0
*/
export type Keys<B extends Brand<any>> = keyof B[typeof TypeId]
export type Keys<B extends Brand<any, any>> = keyof B[typeof TypeId]

/**
* A utility type to extract the brands from a branded type.
*
* @category utility types
* @since 2.0.0
*/
export type Brands<B extends Brand<any>> = Types.UnionToIntersection<
{ [K in Keys<B>]: K extends string ? Brand<K> : never }[Keys<B>]
export type Brands<B extends Brand<any, any>> = Types.UnionToIntersection<
{ [K in Keys<B>]: K extends string ? Brand<K, B extends Brand<K, infer P> ? P : never> : never }[Keys<B>]
>

/**
Expand All @@ -200,6 +212,14 @@ export declare namespace Brand {
: Brands[B]
: "ERROR: All brands should have the same base type"
}

export type Parse<B extends Brand<any, any>, V> = Types.UnionToIntersection<
{
[K in Keys<B>]: K extends string ? B extends Brand<K, infer P> ? (P & { value: V })["result"]
: never
: never
}[Keys<B>]
>
}

/**
Expand All @@ -208,7 +228,11 @@ export declare namespace Brand {
* @category utility types
* @since 2.0.0
*/
export type Branded<A, Key extends string> = A & Brand<Key>
export type Branded<
A,
Key extends string,
LiteralParser extends { value: unknown; result: unknown } = IdentityLiteralParser
> = A & Brand<Key, LiteralParser>

/**
* Returns a `Constructor` that **does not apply any runtime checks** and just
Expand All @@ -225,8 +249,9 @@ export type Branded<A, Key extends string> = A & Brand<Key>
* @category constructors
* @since 2.0.0
*/
export function nominal<A extends Brand<any>>(): Constructor<A> {
export function nominal<A extends Brand<any, any>>(): Constructor<A> {
return Object.assign((input: Brand.Unbranded<A>) => input as A, {
literal: (input: Brand.Unbranded<A>) => input as A,
option: (input: Brand.Unbranded<A>) => Option.some(input as A),
result: (input: Brand.Unbranded<A>) => Result.succeed(input as A),
is: (_: Brand.Unbranded<A>): _ is Brand.Unbranded<A> & A => true
Expand All @@ -246,7 +271,7 @@ export function nominal<A extends Brand<any>>(): Constructor<A> {
* @category constructors
* @since 4.0.0
*/
export function make<A extends Brand<any>>(
export function make<A extends Brand<any, any>>(
filter: (unbranded: Brand.Unbranded<A>) => Schema.FilterOutput
): Constructor<A> {
return check(SchemaAST.makeFilter(filter))
Expand All @@ -271,7 +296,7 @@ export function make<A extends Brand<any>>(
* @category constructors
* @since 4.0.0
*/
export function check<A extends Brand<any>>(
export function check<A extends Brand<any, any>>(
...checks: readonly [
SchemaAST.Check<Brand.Unbranded<A>>,
...Array<SchemaAST.Check<Brand.Unbranded<A>>>
Expand All @@ -281,6 +306,7 @@ export function check<A extends Brand<any>>(
return Result.mapError(SchemaAST.runChecks(checks, input), (issue) => new BrandError(issue)) as any
}
return Object.assign((input: Brand.Unbranded<A>) => Result.getOrThrow(result(input)), {
literal: (input: Brand.Unbranded<A>) => Result.getOrThrow(result(input)),
option: (input: Brand.Unbranded<A>) => Option.getSuccess(result(input)),
result,
is: (input: Brand.Unbranded<A>): input is Brand.Unbranded<A> & A => Result.isSuccess(result(input)),
Expand Down Expand Up @@ -309,7 +335,7 @@ export function all<Brands extends readonly [Constructor<any>, ...Array<Construc
...brands: Brand.EnsureCommonBase<Brands>
): Constructor<
Types.UnionToIntersection<{ [B in keyof Brands]: Brand.FromConstructor<Brands[B]> }[number]> extends
infer X extends Brand<any> ? X : Brand<any>
infer X extends Brand<any, any> ? X : Brand<any, any>
> {
const checks = brands.flatMap((brand) => brand.checks ?? [])
return Arr.isArrayNonEmpty(checks) ?
Expand Down
2 changes: 1 addition & 1 deletion packages/effect/src/Schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5064,7 +5064,7 @@ export function brand<B extends string>(identifier: B) {
* @category branding
* @since 3.10.0
*/
export function fromBrand<A extends Brand.Brand<any>>(identifier: string, ctor: Brand.Constructor<A>) {
export function fromBrand<A extends Brand.Brand<any, any>>(identifier: string, ctor: Brand.Constructor<A>) {
return <S extends Top & { readonly "Type": Brand.Brand.Unbranded<A> }>(
self: S
): brand<S["Rebuild"], Brand.Brand.Keys<A>> => {
Expand Down
20 changes: 18 additions & 2 deletions packages/effect/test/Brand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import {
} from "@effect/vitest/utils"
import { Brand, Result, Schema } from "effect"

function assertSuccess<T extends Brand.Brand<any>>(ctor: Brand.Constructor<T>, value: Brand.Brand.Unbranded<T>) {
function assertSuccess<T extends Brand.Brand<any, any>>(ctor: Brand.Constructor<T>, value: Brand.Brand.Unbranded<T>) {
vassertSuccess(ctor.result(value), value as T)
}

function assertFailure<T extends Brand.Brand<any>>(
function assertFailure<T extends Brand.Brand<any, any>>(
ctor: Brand.Constructor<T>,
value: Brand.Brand.Unbranded<T>,
message: string
Expand Down Expand Up @@ -157,4 +157,20 @@ Expected a value greater than 0`
Expected a value greater than 0`
)
})

it("literal is same as the constructor function in runtime", () => {
type Int = number & Brand.Brand<"Int", IntParser>
interface IntParser {
value: unknown
result: this["value"] extends number ? `${this["value"]}` extends `${bigint}` ? this["value"]
: { IntParserError: "Expected an integer" }
: number
}

const Int = Brand.make<Int>((n) => Number.isInteger(n))

// @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'number & { IntParserError: "Expected an Integer"; }'
throws(() => Int.literal(3.14))
strictEqual(Int.literal(42), 42)
})
})
94 changes: 94 additions & 0 deletions packages/effect/typetest/Brand.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ describe("Brand", () => {

type PositiveInt = number & Brand.Brand<"Int"> & Brand.Brand<"Positive">
expect<Brand.Brand.Brands<PositiveInt>>().type.toBe<Brand.Brand<"Int"> & Brand.Brand<"Positive">>()

type PositiveIntWithParser = number & Brand.Brand<"Int", IntParser> & Brand.Brand<"Positive">
interface IntParser {
value: unknown
result: this["value"] extends number ? `${this["value"]}` extends `${bigint}` ? this["value"]
: { IntParserError: "Expected an interger" }
: number
}
expect<Brand.Brand.Brands<PositiveIntWithParser>>().type.toBe<
Brand.Brand<"Int", IntParser> & Brand.Brand<"Positive">
>()
})

it("EnsureCommonBase", () => {
Expand All @@ -48,4 +59,87 @@ describe("Brand", () => {
[typeof MyString, "ERROR: All brands should have the same base type"]
>()
})

describe("literal", () => {
it("parses the value using the parser on the brand", () => {
type Int = number & Brand.Brand<"Int", IntParser>
interface IntParser {
value: unknown
result: this["value"] extends number ? `${this["value"]}` extends `${bigint}` ? this["value"]
: { IntParserError: "Expected an integer" }
: number
}

const Int = Brand.make<Int>((n) => Number.isInteger(n))

// @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'number & { IntParserError: "Expected an integer"; }'
Int.literal(3.14)
Int.literal(42)
Int.literal(-1)

type StateMachine =
& { initial: string; states: Record<string, string> }
& Brand.Brand<"StateMachine", StateMachineParser>
interface StateMachineParser {
value: unknown
result: this["value"] extends infer Value extends Brand.Brand.Unbranded<StateMachine>
? { initial: keyof Value["states"]; states: Record<string, keyof Value["states"]> }
: Brand.Brand.Unbranded<StateMachine>
}
const StateMachine = Brand.nominal<StateMachine>()

StateMachine.literal({
// @ts-expect-error Type '"LOL"' is not assignable to type '"YELLOW" | "RED" | "GREEN"'
initial: "LOL",
states: {
RED: "YELLOW",
YELLOW: "GREEN",
GREEN: "RED"
}
})

StateMachine.literal({
initial: "RED",
states: {
RED: "YELLOW",
YELLOW: "GREEN",
GREEN: "RED"
}
})
})

it("parses the value using all parsers on the brand", () => {
type Int = number & Brand.Brand<"Int", IntParser>
interface IntParser {
value: unknown
result: this["value"] extends number ? `${this["value"]}` extends `${bigint}` ? this["value"]
: { IntParserError: "Expected an integer" }
: number
}
const Int = Brand.make<Int>((n) => Number.isInteger(n))

type Positive = number & Brand.Brand<"Positive", PositiveParser>
interface PositiveParser {
value: unknown
result: this["value"] extends number
? `${this["value"]}` extends `-${number}` ? { PositiveParserError: "Expected a positive number" }
: this["value"]
: number
}
const Positive = Brand.make<Positive>((n) => n > 0)

const PositiveInt = Brand.all(Int, Positive)

// @ts-expect-error Argument of type 'number' is not assignable to parameter of type 'number & { IntParserError: "Expected an integer"; } & { PositiveParserError: "Expected a positive number"; }'
PositiveInt.literal(-1.2)

// @ts-expect-error Argument of type '1.2' is not assignable to parameter of type '{ IntParserError: "Expected an integer"; } & 1.2'
PositiveInt.literal(1.2)

// @ts-expect-error Argument of type '-1' is not assignable to parameter of type '-1 & { PositiveParserError: "Expected a positive number"; }'
PositiveInt.literal(-1)

PositiveInt.literal(1)
})
})
})