diff --git a/.changeset/tasty-bulldogs-share.md b/.changeset/tasty-bulldogs-share.md new file mode 100644 index 000000000..dcec4de0e --- /dev/null +++ b/.changeset/tasty-bulldogs-share.md @@ -0,0 +1,5 @@ +--- +"@effect/schema": patch +--- + +add BrandSchema, getOption diff --git a/docs/modules/Parser.ts.md b/docs/modules/Parser.ts.md index 86a8ca0fa..9f0333b50 100644 --- a/docs/modules/Parser.ts.md +++ b/docs/modules/Parser.ts.md @@ -20,6 +20,7 @@ Added in v1.0.0 - [decoding](#decoding) - [decode](#decode) - [decodeOrThrow](#decodeorthrow) + - [getOption](#getoption) - [encoding](#encoding) - [encode](#encode) - [encodeOrThrow](#encodeorthrow) @@ -100,6 +101,18 @@ export declare const decodeOrThrow: ( Added in v1.0.0 +## getOption + +**Signature** + +```ts +export declare const getOption: ( + schema: Schema +) => (input: unknown, options?: AST.ParseOptions | undefined) => O.Option +``` + +Added in v1.0.0 + # encoding ## encode diff --git a/docs/modules/Schema.ts.md b/docs/modules/Schema.ts.md index 390b33fc0..41eaa93f7 100644 --- a/docs/modules/Schema.ts.md +++ b/docs/modules/Schema.ts.md @@ -77,6 +77,7 @@ Added in v1.0.0 - [startsWith](#startswith) - [trimmed](#trimmed) - [model](#model) + - [BrandSchema (interface)](#brandschema-interface) - [Schema (interface)](#schema-interface) - [parsers](#parsers) - [clamp](#clamp) @@ -262,7 +263,7 @@ Schema + B -> Schema> export declare const brand: ( brand: B, options?: AnnotationOptions | undefined -) => (self: Schema) => Schema +) => (self: Schema) => BrandSchema ``` **Example** @@ -481,8 +482,8 @@ using the provided decoding functions. ```ts export declare const transformOrFail: ( to: Schema, - decode: (input: A, options?: AST.ParseOptions | undefined) => Either, - encode: (input: B, options?: AST.ParseOptions | undefined) => Either + decode: (input: A, options?: AST.ParseOptions | undefined) => E.Either, + encode: (input: B, options?: AST.ParseOptions | undefined) => E.Either ) => (self: Schema) => Schema ``` @@ -896,6 +897,16 @@ Added in v1.0.0 # model +## BrandSchema (interface) + +**Signature** + +```ts +export interface BrandSchema> extends Schema, Brand.Constructor {} +``` + +Added in v1.0.0 + ## Schema (interface) **Signature** diff --git a/docs/modules/index.ts.md b/docs/modules/index.ts.md index 4656fcf6b..19ee0f8ce 100644 --- a/docs/modules/index.ts.md +++ b/docs/modules/index.ts.md @@ -21,6 +21,7 @@ Added in v1.0.0 - [encodeOrThrow](#encodeorthrow) - [failure](#failure) - [failures](#failures) + - [getOption](#getoption) - [is](#is) - [isFailure](#isfailure) - [isSuccess](#issuccess) @@ -118,6 +119,18 @@ export declare const failures: ( Added in v1.0.0 +## getOption + +**Signature** + +```ts +export declare const getOption: ( + schema: Schema +) => (input: unknown, options?: ParseOptions | undefined) => Option +``` + +Added in v1.0.0 + ## is **Signature** diff --git a/src/Parser.ts b/src/Parser.ts index d84f241e1..19d426151 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -2,20 +2,10 @@ * @since 1.0.0 */ -import { isBoolean } from "@effect/data/Boolean" import { pipe } from "@effect/data/Function" -import { isNumber } from "@effect/data/Number" import * as O from "@effect/data/Option" -import { - isBigint, - isNever, - isNotNullable, - isObject, - isRecord, - isString, - isSymbol, - isUndefined -} from "@effect/data/Predicate" +import type { Option } from "@effect/data/Option" +import * as P from "@effect/data/Predicate" import * as RA from "@effect/data/ReadonlyArray" import * as H from "@effect/schema/annotation/Hook" import * as AST from "@effect/schema/AST" @@ -48,6 +38,14 @@ export const decode = ( schema: Schema ): (input: unknown, options?: ParseOptions) => ParseResult => parserFor(schema).parse +/** + * @category decoding + * @since 1.0.0 + */ +export const getOption = (schema: Schema) => + (input: unknown, options?: ParseOptions): Option => + O.fromEither(parserFor(schema).parse(input, options)) + /** * @category decoding * @since 1.0.0 @@ -139,26 +137,26 @@ const parserFor = ( (u): u is typeof ast.symbol => u === ast.symbol ) case "UndefinedKeyword": - return I.fromRefinement(I.makeSchema(ast), isUndefined) + return I.fromRefinement(I.makeSchema(ast), P.isUndefined) case "VoidKeyword": - return I.fromRefinement(I.makeSchema(ast), isUndefined) + return I.fromRefinement(I.makeSchema(ast), P.isUndefined) case "NeverKeyword": - return I.fromRefinement(I.makeSchema(ast), isNever) + return I.fromRefinement(I.makeSchema(ast), P.isNever) case "UnknownKeyword": case "AnyKeyword": return make(I.makeSchema(ast), PR.success) case "StringKeyword": - return I.fromRefinement(I.makeSchema(ast), isString) + return I.fromRefinement(I.makeSchema(ast), P.isString) case "NumberKeyword": - return I.fromRefinement(I.makeSchema(ast), isNumber) + return I.fromRefinement(I.makeSchema(ast), P.isNumber) case "BooleanKeyword": - return I.fromRefinement(I.makeSchema(ast), isBoolean) + return I.fromRefinement(I.makeSchema(ast), P.isBoolean) case "BigIntKeyword": - return I.fromRefinement(I.makeSchema(ast), isBigint) + return I.fromRefinement(I.makeSchema(ast), P.isBigint) case "SymbolKeyword": - return I.fromRefinement(I.makeSchema(ast), isSymbol) + return I.fromRefinement(I.makeSchema(ast), P.isSymbol) case "ObjectKeyword": - return I.fromRefinement(I.makeSchema(ast), isObject) + return I.fromRefinement(I.makeSchema(ast), P.isObject) case "Enums": return I.fromRefinement( I.makeSchema(ast), @@ -166,7 +164,7 @@ const parserFor = ( ) case "TemplateLiteral": { const regex = I.getTemplateLiteralRegex(ast) - return I.fromRefinement(I.makeSchema(ast), (u): u is any => isString(u) && regex.test(u)) + return I.fromRefinement(I.makeSchema(ast), (u): u is any => P.isString(u) && regex.test(u)) } case "Tuple": { const elements = ast.elements.map((e) => go(e.type)) @@ -285,7 +283,7 @@ const parserFor = ( } case "TypeLiteral": { if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { - return I.fromRefinement(I.makeSchema(ast), isNotNullable) + return I.fromRefinement(I.makeSchema(ast), P.isNotNullable) } const propertySignaturesTypes = ast.propertySignatures.map((f) => go(f.type)) const indexSignatures = ast.indexSignatures.map((is) => @@ -294,7 +292,7 @@ const parserFor = ( return make( I.makeSchema(ast), (input: unknown, options) => { - if (!isRecord(input)) { + if (!P.isRecord(input)) { return PR.failure(PR.type(unknownRecord, input)) } const output: any = {} @@ -417,7 +415,7 @@ const parserFor = ( if (len > 0) { // if there is at least one key then input must be an object - if (isRecord(input)) { + if (P.isRecord(input)) { for (let i = 0; i < len; i++) { const name = ownKeys[i] const buckets = searchTree.keys[name].buckets diff --git a/src/Schema.ts b/src/Schema.ts index 58d93e7df..2761e302c 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -3,6 +3,8 @@ */ import type { Brand } from "@effect/data/Brand" +import { RefinedConstructorsTypeId } from "@effect/data/Brand" +import * as E from "@effect/data/Either" import { pipe } from "@effect/data/Function" import type { Option } from "@effect/data/Option" import type { Predicate, Refinement } from "@effect/data/Predicate" @@ -12,11 +14,13 @@ import * as AST from "@effect/schema/AST" import type { ParseOptions } from "@effect/schema/AST" import * as DataDate from "@effect/schema/data/Date" import * as N from "@effect/schema/data/Number" -import * as O from "@effect/schema/data/Object" +import * as DataObject from "@effect/schema/data/Object" import * as DataOption from "@effect/schema/data/Option" import * as SRA from "@effect/schema/data/ReadonlyArray" import * as S from "@effect/schema/data/String" +import { formatErrors } from "@effect/schema/formatter/Tree" import * as I from "@effect/schema/internal/common" +import * as P from "@effect/schema/Parser" import type { ParseResult } from "@effect/schema/ParseResult" /** @@ -82,7 +86,7 @@ export const enums = ( export const instanceOf: any>( constructor: A, annotationOptions?: AnnotationOptions -) => Schema> = O.instanceOf +) => Schema> = DataObject.instanceOf /** * @since 1.0.0 @@ -563,6 +567,12 @@ export const getPropertySignatures = (schema: Schema): { [K in keyof A]: S return out as any } +/** + * @category model + * @since 1.0.0 + */ +export interface BrandSchema> extends Schema, Brand.Constructor {} + /** * Returns a nominal branded schema by applying a brand to a given schema. * @@ -583,10 +593,32 @@ export const getPropertySignatures = (schema: Schema): { [K in keyof A]: S * @category combinators * @since 1.0.0 */ -export const brand: ( +export const brand = ( brand: B, options?: AnnotationOptions -) => (self: Schema) => Schema> = I.brand +) => + (self: Schema): BrandSchema> => { + const annotations = I.toAnnotations(options) + annotations[A.BrandId] = [...getBrands(self.ast), brand] + const ast = AST.mergeAnnotations(self.ast, annotations) + const schema: Schema> = make(ast) + const decodeOrThrow = P.decodeOrThrow(schema) + const getOption = P.getOption(schema) + const decode = P.decode(schema) + const is = P.is(schema) + const out: any = Object.assign((input: unknown) => decodeOrThrow(input), { + [RefinedConstructorsTypeId]: RefinedConstructorsTypeId, + ast, + option: (input: unknown) => getOption(input), + either: (input: unknown) => + E.mapLeft(decode(input), (errors) => [{ meta: input, message: formatErrors(errors) }]), + refine: (input: unknown): input is A & Brand => is(input) + }) + return out + } + +const getBrands = (ast: AST.AST): Array => + (ast.annotations[A.BrandId] as Array | undefined) || [] /** * @category combinators diff --git a/src/index.ts b/src/index.ts index c598f6796..569a76f01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,10 @@ export { * @since 1.0.0 */ encodeOrThrow, + /** + * @since 1.0.0 + */ + getOption, /** * @since 1.0.0 */ diff --git a/src/internal/common.ts b/src/internal/common.ts index 1ac634f21..9b0dec866 100644 --- a/src/internal/common.ts +++ b/src/internal/common.ts @@ -2,7 +2,6 @@ * @since 1.0.0 */ -import type { Brand } from "@effect/data/Brand" import * as E from "@effect/data/Either" import { pipe } from "@effect/data/Function" import * as O from "@effect/data/Option" @@ -84,7 +83,10 @@ export const typeAlias = ( export const annotations = (annotations: AST.Annotated["annotations"]) => (self: S.Schema): S.Schema => makeSchema(AST.mergeAnnotations(self.ast, annotations)) -const toAnnotations = (options?: S.AnnotationOptions): AST.Annotated["annotations"] => { +/** @internal */ +export const toAnnotations = ( + options?: S.AnnotationOptions +): AST.Annotated["annotations"] => { const annotations: AST.Annotated["annotations"] = {} if (options?.typeId !== undefined) { const typeId = options?.typeId @@ -135,18 +137,6 @@ export function filter( return (from) => makeSchema(AST.createRefinement(from.ast, predicate, toAnnotations(options))) } -const getBrands = (ast: AST.AST): Array => - (ast.annotations[A.BrandId] as Array | undefined) || [] - -/** @internal */ -export const brand = (brand: B, options?: S.AnnotationOptions) => - (self: S.Schema): S.Schema> => { - const annotations = toAnnotations(options) - annotations[A.BrandId] = [...getBrands(self.ast), brand] - const ast = AST.mergeAnnotations(self.ast, annotations) - return makeSchema(ast) - } - /** @internal */ export const transformOrFail = ( to: S.Schema, diff --git a/test/Schema.ts b/test/Schema.ts index e0db3f3d8..0344446c3 100644 --- a/test/Schema.ts +++ b/test/Schema.ts @@ -1,4 +1,6 @@ +import * as E from "@effect/data/Either" import { pipe } from "@effect/data/Function" +import * as O from "@effect/data/Option" import * as S from "@effect/schema" import * as A from "@effect/schema/annotation/AST" import * as AST from "@effect/schema/AST" @@ -21,7 +23,7 @@ describe.concurrent("Schema", () => { expect(S.annotations).exist }) - it("brand", () => { + it("brand/ annotations", () => { // const Branded: S.Schema & Brand<"B">> const Branded = pipe( S.number, @@ -39,6 +41,50 @@ describe.concurrent("Schema", () => { }) }) + it("brand/ ()", () => { + const Int = pipe(S.number, S.int(), S.brand("Int")) + expect(Int(1)).toEqual(1) + expect(() => Int(1.2)).toThrowError( + new Error(`1 error(s) found +└─ Expected integer, actual 1.2`) + ) + }) + + it("brand/ option", () => { + const Int = pipe(S.number, S.int(), S.brand("Int")) + expect(Int.option(1)).toEqual(O.some(1)) + expect(Int.option(1.2)).toEqual(O.none()) + }) + + it("brand/ either", () => { + const Int = pipe(S.number, S.int(), S.brand("Int")) + expect(Int.either(1)).toEqual(E.right(1)) + expect(Int.either(1.2)).toEqual(E.left([{ + meta: 1.2, + message: `1 error(s) found +└─ Expected integer, actual 1.2` + }])) + }) + + it("brand/ refine", () => { + const Int = pipe(S.number, S.int(), S.brand("Int")) + expect(Int.refine(1)).toEqual(true) + expect(Int.refine(1.2)).toEqual(false) + }) + + it("brand/ composition", () => { + const int = (self: S.Schema) => pipe(self, S.int(), S.brand("Int")) + + const positive = (self: S.Schema) => + pipe(self, S.positive(), S.brand("Positive")) + + const PositiveInt = pipe(S.number, int, positive) + + expect(PositiveInt.refine(1)).toEqual(true) + expect(PositiveInt.refine(-1)).toEqual(false) + expect(PositiveInt.refine(1.2)).toEqual(false) + }) + it("getPropertySignatures", () => { const Name = pipe(S.string, S.identifier("name")) const Age = pipe(S.number, S.identifier("age"))