diff --git a/.changeset/plenty-apples-dream.md b/.changeset/plenty-apples-dream.md new file mode 100644 index 000000000..a295f047f --- /dev/null +++ b/.changeset/plenty-apples-dream.md @@ -0,0 +1,6 @@ +--- +"effect-app": minor +"@effect-app/schema": minor +--- + +fix branded schemas diff --git a/packages/prelude/src/schema.ts b/packages/prelude/src/schema.ts index aedf15323..b886108a0 100644 --- a/packages/prelude/src/schema.ts +++ b/packages/prelude/src/schema.ts @@ -1,37 +1,30 @@ import { Option, pipe, ReadonlyArray } from "@effect-app/core" -import { isValidEmail, isValidPhone } from "@effect-app/core/validation" -import { type A, type Email as EmailT, fromBrand, nominal, type PhoneNumber as PhoneNumberT } from "@effect-app/schema" +import { type A, type Email as EmailT, type PhoneNumber as PhoneNumberT } from "@effect-app/schema" import * as S from "@effect-app/schema" import { fakerArb } from "./faker.js" import { extendM } from "./utils.js" +export * from "@effect-app/schema" + export const Email = S - .string + .Email .pipe( - S.filter(isValidEmail, { - title: "Email", - description: "an email according to RFC 5322", - jsonSchema: { format: "email" }, + S.annotations({ // eslint-disable-next-line @typescript-eslint/unbound-method - arbitrary: (): A.Arbitrary => fakerArb((faker) => faker.internet.exampleEmail) + arbitrary: (): A.Arbitrary => (fc) => fakerArb((faker) => faker.internet.exampleEmail)(fc).map(Email) }), - fromBrand(nominal(), { jsonSchema: {} }), S.withDefaults ) export type Email = EmailT export const PhoneNumber = S - .string + .PhoneNumber .pipe( - S.filter(isValidPhone, { - title: "PhoneNumber", - description: "a phone number with at least 7 digits", - jsonSchema: { format: "phone" }, + S.annotations({ // eslint-disable-next-line @typescript-eslint/unbound-method - arbitrary: (): A.Arbitrary => fakerArb((faker) => faker.phone.number) + arbitrary: (): A.Arbitrary => (fc) => fakerArb((faker) => faker.phone.number)(fc).map(PhoneNumber) }), - fromBrand(nominal(), { jsonSchema: {} }), S.withDefaults ) @@ -92,5 +85,3 @@ export const taggedUnion = extendM(_, (_) => ({ is: makeIs(_), isAnyOf: makeIsAnyOf(_) }))) export type PhoneNumber = PhoneNumberT - -export * from "@effect-app/schema" diff --git a/packages/prelude/test/schema.test.ts b/packages/prelude/test/schema.test.ts index b8a04137b..1d766da50 100644 --- a/packages/prelude/test/schema.test.ts +++ b/packages/prelude/test/schema.test.ts @@ -1,6 +1,15 @@ -import { StringId } from "@effect-app/schema" +import { generateFromArbitrary } from "@effect-app/infra/test.arbs" +import { JSONSchema } from "@effect/schema" +import { ReadonlyArray, S } from "effect-app" import { test } from "vitest" +const A = S.struct({ a: S.NonEmptyString255, email: S.nullable(S.Email) }) test("works", () => { - console.log(StringId.make()) + console.log(S.StringId.make()) + console.log(generateFromArbitrary(S.A.make(A)).value) + console.log(S.AST.getTitleAnnotation(S.Email.ast)) + console.log(S.AST.getDescriptionAnnotation(S.Email.ast)) + console.log(S.AST.getJSONSchemaAnnotation(S.Email.ast)) + console.log(JSONSchema.make(S.Email)) + console.log(S.decodeEither(A, { errors: "all" })({ a: ReadonlyArray.range(1, 256).join(""), email: "hello" })) }) diff --git a/packages/schema/src/email.ts b/packages/schema/src/email.ts index f340805a9..6a17e31ec 100644 --- a/packages/schema/src/email.ts +++ b/packages/schema/src/email.ts @@ -1,7 +1,6 @@ -import { isValidEmail } from "@effect-app/core/validation" +import { isValidEmail as isValidEmailOrig } from "@effect-app/core/validation" import * as S from "@effect/schema/Schema" import type { Simplify } from "effect/Types" -import { fromBrand, nominal } from "./brand.js" import { withDefaults } from "./ext.js" import type { B } from "./schema.js" import type { NonEmptyStringBrand } from "./strings.js" @@ -10,16 +9,19 @@ export interface EmailBrand extends Simplify (fc) => fc.emailAddress() }), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("Email"), withDefaults ) diff --git a/packages/schema/src/moreStrings.ts b/packages/schema/src/moreStrings.ts index 62e554324..1e7c07ce5 100644 --- a/packages/schema/src/moreStrings.ts +++ b/packages/schema/src/moreStrings.ts @@ -27,9 +27,8 @@ export type NonEmptyString50 = string & NonEmptyString50Brand * A string that is at least 1 character long and a maximum of 50. */ export const NonEmptyString50 = NonEmptyString.pipe( - S.maxLength(50, { title: "NonEmptyString50" }), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("NonEmptyString50"), + S.maxLength(50), + fromBrand(nominal(), { identifier: "NonEmptyString50", title: "NonEmptyString50", jsonSchema: {} }), withDefaults ) @@ -49,9 +48,8 @@ export type Min3String255 = string & Min3String255Brand export const Min3String255 = pipe( S.string, S.minLength(3), - S.maxLength(255, { title: "Min3String255" }), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("Min3String255"), + S.maxLength(255), + fromBrand(nominal(), { identifier: "Min3String255", title: "Min3String255", jsonSchema: {} }), withDefaults ) @@ -82,9 +80,13 @@ export const StringId = extendM( pipe( S.string, S.minLength(minLength), - S.maxLength(maxLength, { title: "StringId", arbitrary: StringIdArb }), - S.identifier("StringId"), - fromBrand(nominal(), { jsonSchema: {} }) + S.maxLength(maxLength), + fromBrand(nominal(), { + identifier: "StringId", + title: "StringId", + arbitrary: StringIdArb, + jsonSchema: {} + }) ), (s) => ({ make: makeStringId, @@ -125,7 +127,11 @@ export function prefixedStringId() { ) const s: S.Schema = StringId .pipe( - S.filter((x: StringId): x is Brand => x.startsWith(pref), { arbitrary: arb, title: name }) + S.filter((x: StringId): x is string & Brand => x.startsWith(pref), { + arbitrary: arb, + identifier: name, + title: name + }) ) const schema = s.pipe(withDefaults) const make = () => (pref + StringId.make().substring(0, 50 - pref.length)) as Brand @@ -173,20 +179,20 @@ export interface PrefixedStringUtils< export interface UrlBrand extends Simplify & NonEmptyStringBrand> {} -export type Url = NonEmptyString & UrlBrand +export type Url = string & UrlBrand const isUrl: Refinement = (s: string): s is Url => { return validator.default.isURL(s, { require_tld: false }) } -export const Url = NonEmptyString +export const Url = S + .string .pipe( S.filter(isUrl, { arbitrary: (): Arbitrary => (fc) => fc.webUrl(), + identifier: "Url", title: "Url", jsonSchema: { format: "uri" } }), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("Url"), withDefaults ) diff --git a/packages/schema/src/numbers.ts b/packages/schema/src/numbers.ts index 212a1c79a..4525fc765 100644 --- a/packages/schema/src/numbers.ts +++ b/packages/schema/src/numbers.ts @@ -11,8 +11,7 @@ export interface PositiveIntBrand export const PositiveInt = extendM( S.Int.pipe( S.positive(), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("PositiveInt"), + fromBrand(nominal(), { identifier: "PositiveInt", title: "PositiveInt", jsonSchema: {} }), withDefaults ), (s) => ({ withDefault: S.propertySignature(s, { default: () => s(1) }) }) @@ -23,8 +22,11 @@ export interface NonNegativeIntBrand extends Simplify export const NonNegativeInt = extendM( S.Int.pipe( S.nonNegative(), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("NonNegativeInt"), + fromBrand(nominal(), { + identifier: "NonNegativeInt", + title: "NonNegativeInt", + jsonSchema: {} + }), withDefaults ), (s) => ({ withDefault: S.propertySignature(s, { default: () => s(0) }) }) @@ -33,7 +35,7 @@ export type NonNegativeInt = S.Schema.Type export interface IntBrand extends Simplify> {} export const Int = extendM( - S.Int.pipe(fromBrand(nominal(), { jsonSchema: {} }), S.identifier("Int"), withDefaults), + S.Int.pipe(fromBrand(nominal(), { identifier: "Int", title: "Int", jsonSchema: {} }), withDefaults), (s) => ({ withDefault: S.propertySignature(s, { default: () => s(0) }) }) ) export type Int = S.Schema.Type @@ -42,8 +44,11 @@ export interface PositiveNumberBrand extends Simplify export const PositiveNumber = extendM( S.number.pipe( S.positive(), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("PositiveNumber"), + fromBrand(nominal(), { + identifier: "PositiveNumber", + title: "PositiveNumber", + jsonSchema: {} + }), withDefaults ), (s) => ({ withDefault: S.propertySignature(s, { default: () => s(1) }) }) @@ -56,8 +61,11 @@ export const NonNegativeNumber = extendM( .number .pipe( S.nonNegative(), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("NonNegativeNumber"), + fromBrand(nominal(), { + identifier: "NonNegativeNumber", + title: "NonNegativeNumber", + jsonSchema: {} + }), withDefaults ), (s) => ({ withDefault: S.propertySignature(s, { default: () => s(0) }) }) diff --git a/packages/schema/src/phoneNumber.ts b/packages/schema/src/phoneNumber.ts index 3de7e42e5..61f447ffb 100644 --- a/packages/schema/src/phoneNumber.ts +++ b/packages/schema/src/phoneNumber.ts @@ -1,12 +1,15 @@ -import { isValidPhone } from "@effect-app/core/validation" +import { isValidPhone as isValidPhoneOrig } from "@effect-app/core/validation" import * as S from "@effect/schema/Schema" import type { Simplify } from "effect/Types" -import { fromBrand, nominal } from "./brand.js" import { withDefaults } from "./ext.js" import { Numbers } from "./FastCheck.js" import type { B } from "./schema.js" import type { NonEmptyStringBrand } from "./strings.js" +function isValidPhone(str: string): str is PhoneNumber { + return isValidPhoneOrig(str) +} + export interface PhoneNumberBrand extends Simplify & NonEmptyStringBrand> {} export type PhoneNumber = string & PhoneNumberBrand @@ -14,12 +17,11 @@ export const PhoneNumber = S .string .pipe( S.filter(isValidPhone, { + identifier: "PhoneNumber", title: "PhoneNumber", description: "a phone number with at least 7 digits", arbitrary: () => Numbers(7, 10), jsonSchema: { format: "phone" } }), - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("PhoneNumber"), withDefaults ) diff --git a/packages/schema/src/strings.ts b/packages/schema/src/strings.ts index 46985b1ff..46198d5d2 100644 --- a/packages/schema/src/strings.ts +++ b/packages/schema/src/strings.ts @@ -4,14 +4,13 @@ import type { Simplify } from "effect/Types" import { fromBrand, nominal } from "./brand.js" import { withDefaults } from "./ext.js" -const nonEmptyString = S.string.pipe(S.nonEmpty({ title: "NonEmptyString" })) +const nonEmptyString = S.string.pipe(S.nonEmpty()) export type NonEmptyStringBrand = B.Brand<"NonEmptyString"> export type NonEmptyString = string & NonEmptyStringBrand export const NonEmptyString = nonEmptyString .pipe( - fromBrand(nominal(), { jsonSchema: {} }), - S.identifier("NonEmptyString"), + fromBrand(nominal(), { identifier: "NonEmptyString", title: "NonEmptyString", jsonSchema: {} }), withDefaults ) @@ -19,9 +18,12 @@ export interface NonEmptyString64kBrand extends Simplify(), { jsonSchema: {} }), - S.identifier("NonEmptyString64k"), + S.maxLength(64 * 1024), + fromBrand(nominal(), { + identifier: "NonEmptyString64k", + title: "NonEmptyString64k", + jsonSchema: {} + }), withDefaults ) @@ -29,9 +31,12 @@ export interface NonEmptyString2kBrand extends Simplify(), { jsonSchema: {} }), - S.identifier("NonEmptyString2k"), + S.maxLength(2 * 1024), + fromBrand(nominal(), { + identifier: "NonEmptyString2k", + title: "NonEmptyString2k", + jsonSchema: {} + }), withDefaults ) @@ -39,8 +44,11 @@ export interface NonEmptyString255Brand extends Simplify(), { jsonSchema: {} }), - S.identifier("NonEmptyString255"), + S.maxLength(255), + fromBrand(nominal(), { + identifier: "NonEmptyString255", + title: "NonEmptyString255", + jsonSchema: {} + }), withDefaults )