From 0732d4c92d8658b8359f450747b3189189658bbe Mon Sep 17 00:00:00 2001 From: Devansh Jethmalani Date: Wed, 16 Sep 2026 19:38:06 +0530 Subject: [PATCH 1/3] feat: `Brand.literal` --- packages/effect/src/Brand.ts | 50 ++++++++++---- packages/effect/src/Schema.ts | 2 +- packages/effect/test/Brand.test.ts | 20 +++++- packages/effect/typetest/Brand.tst.ts | 94 +++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 15 deletions(-) diff --git a/packages/effect/src/Brand.ts b/packages/effect/src/Brand.ts index 1cbba59fdfa..ffb185fe6e1 100644 --- a/packages/effect/src/Brand.ts +++ b/packages/effect/src/Brand.ts @@ -32,12 +32,20 @@ const TypeId = "~effect/Brand" * @category models * @since 2.0.0 */ -export interface Brand { +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. @@ -56,12 +64,16 @@ export interface Brand { * @category models * @since 2.0.0 */ -export interface Constructor> { +export interface Constructor> { /** * Constructs a branded type from a value of type `Unbranded`, throwing an * error if the provided value is not valid. */ (unbranded: Brand.Unbranded): B + /** + * Constructs a branded type from a literal + */ + literal & Brand.Parse>(value: T): B /** * Constructs a branded type from a value of type `Unbranded`, returning * `Some` if the provided value is valid, `None` otherwise. @@ -164,7 +176,7 @@ export declare namespace Brand { * @category utility types * @since 2.0.0 */ - export type Unbranded> = B extends infer U & Brands ? U : B + export type Unbranded> = B extends infer U & Brands ? U : B /** * A utility type to extract the keys of a branded type. @@ -172,7 +184,7 @@ export declare namespace Brand { * @category utility types * @since 4.0.0 */ - export type Keys> = keyof B[typeof TypeId] + export type Keys> = keyof B[typeof TypeId] /** * A utility type to extract the brands from a branded type. @@ -180,8 +192,8 @@ export declare namespace Brand { * @category utility types * @since 2.0.0 */ - export type Brands> = Types.UnionToIntersection< - { [K in Keys]: K extends string ? Brand : never }[Keys] + export type Brands> = Types.UnionToIntersection< + { [K in Keys]: K extends string ? Brand ? P : never> : never }[Keys] > /** @@ -200,6 +212,14 @@ export declare namespace Brand { : Brands[B] : "ERROR: All brands should have the same base type" } + + export type Parse, V> = Types.UnionToIntersection< + { + [K in Keys]: K extends string ? B extends Brand ? (P & { value: V })["result"] + : never + : never + }[Keys] + > } /** @@ -208,7 +228,11 @@ export declare namespace Brand { * @category utility types * @since 2.0.0 */ -export type Branded = A & Brand +export type Branded< + A, + Key extends string, + LiteralParser extends { value: unknown; result: unknown } = IdentityLiteralParser +> = A & Brand /** * Returns a `Constructor` that **does not apply any runtime checks** and just @@ -225,8 +249,9 @@ export type Branded = A & Brand * @category constructors * @since 2.0.0 */ -export function nominal>(): Constructor { +export function nominal>(): Constructor { return Object.assign((input: Brand.Unbranded) => input as A, { + literal: (input: Brand.Unbranded) => input as A, option: (input: Brand.Unbranded) => Option.some(input as A), result: (input: Brand.Unbranded) => Result.succeed(input as A), is: (_: Brand.Unbranded): _ is Brand.Unbranded & A => true @@ -246,7 +271,7 @@ export function nominal>(): Constructor { * @category constructors * @since 4.0.0 */ -export function make>( +export function make>( filter: (unbranded: Brand.Unbranded) => Schema.FilterOutput ): Constructor { return check(SchemaAST.makeFilter(filter)) @@ -271,7 +296,7 @@ export function make>( * @category constructors * @since 4.0.0 */ -export function check>( +export function check>( ...checks: readonly [ SchemaAST.Check>, ...Array>> @@ -281,6 +306,7 @@ export function check>( return Result.mapError(SchemaAST.runChecks(checks, input), (issue) => new BrandError(issue)) as any } return Object.assign((input: Brand.Unbranded) => Result.getOrThrow(result(input)), { + literal: (input: Brand.Unbranded) => Result.getOrThrow(result(input)), option: (input: Brand.Unbranded) => Option.getSuccess(result(input)), result, is: (input: Brand.Unbranded): input is Brand.Unbranded & A => Result.isSuccess(result(input)), @@ -309,7 +335,7 @@ export function all, ...Array ): Constructor< Types.UnionToIntersection<{ [B in keyof Brands]: Brand.FromConstructor }[number]> extends - infer X extends Brand ? X : Brand + infer X extends Brand ? X : Brand > { const checks = brands.flatMap((brand) => brand.checks ?? []) return Arr.isArrayNonEmpty(checks) ? diff --git a/packages/effect/src/Schema.ts b/packages/effect/src/Schema.ts index c6897d77b38..8b4cb6a8790 100644 --- a/packages/effect/src/Schema.ts +++ b/packages/effect/src/Schema.ts @@ -5064,7 +5064,7 @@ export function brand(identifier: B) { * @category branding * @since 3.10.0 */ -export function fromBrand>(identifier: string, ctor: Brand.Constructor) { +export function fromBrand>(identifier: string, ctor: Brand.Constructor) { return }>( self: S ): brand> => { diff --git a/packages/effect/test/Brand.test.ts b/packages/effect/test/Brand.test.ts index 8d2c151def6..1add9f46bd9 100644 --- a/packages/effect/test/Brand.test.ts +++ b/packages/effect/test/Brand.test.ts @@ -11,11 +11,11 @@ import { } from "@effect/vitest/utils" import { Brand, Result, Schema } from "effect" -function assertSuccess>(ctor: Brand.Constructor, value: Brand.Brand.Unbranded) { +function assertSuccess>(ctor: Brand.Constructor, value: Brand.Brand.Unbranded) { vassertSuccess(ctor.result(value), value as T) } -function assertFailure>( +function assertFailure>( ctor: Brand.Constructor, value: Brand.Brand.Unbranded, message: string @@ -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((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) + }) }) diff --git a/packages/effect/typetest/Brand.tst.ts b/packages/effect/typetest/Brand.tst.ts index 02db35063ce..efa1c103299 100644 --- a/packages/effect/typetest/Brand.tst.ts +++ b/packages/effect/typetest/Brand.tst.ts @@ -30,6 +30,17 @@ describe("Brand", () => { type PositiveInt = number & Brand.Brand<"Int"> & Brand.Brand<"Positive"> expect>().type.toBe & 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>().type.toBe< + Brand.Brand<"Int", IntParser> & Brand.Brand<"Positive"> + >() }) it("EnsureCommonBase", () => { @@ -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((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 } + & Brand.Brand<"StateMachine", StateMachineParser> + interface StateMachineParser { + value: unknown + result: this["value"] extends infer Value extends Brand.Brand.Unbranded + ? { initial: keyof Value["states"]; states: Record } + : Brand.Brand.Unbranded + } + const StateMachine = Brand.nominal() + + 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((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((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) + }) + }) }) From 3cad3d5da6d7f49d902a95dff9af62de3cd91a9a Mon Sep 17 00:00:00 2001 From: Devansh Jethmalani Date: Wed, 16 Sep 2026 19:50:47 +0530 Subject: [PATCH 2/3] add changeset --- .changeset/lucky-foxes-serve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lucky-foxes-serve.md diff --git a/.changeset/lucky-foxes-serve.md b/.changeset/lucky-foxes-serve.md new file mode 100644 index 00000000000..0321ea425f4 --- /dev/null +++ b/.changeset/lucky-foxes-serve.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +Add ability to parser literals in the program to brands From 0c2dfd188f8445a521919c1367a219a5c13b5eb1 Mon Sep 17 00:00:00 2001 From: Devansh Jethmalani Date: Wed, 16 Sep 2026 20:09:07 +0530 Subject: [PATCH 3/3] fix typo in changeset --- .changeset/lucky-foxes-serve.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lucky-foxes-serve.md b/.changeset/lucky-foxes-serve.md index 0321ea425f4..aa6130e4463 100644 --- a/.changeset/lucky-foxes-serve.md +++ b/.changeset/lucky-foxes-serve.md @@ -2,4 +2,4 @@ "effect": minor --- -Add ability to parser literals in the program to brands +Add ability to parse literals in the program to brands