From 9c3019669b63245dd19b84939326f62e652277d2 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Wed, 9 Aug 2023 15:57:30 +0200 Subject: [PATCH] add `Class` to /Schema module (#372) Co-authored-by: Tim --- .changeset/wicked-chefs-hope.md | 5 + README.md | 87 +++++++++++++++ docs/modules/Schema.ts.md | 118 ++++++++++++++++---- src/Schema.ts | 187 +++++++++++++++++++++++++++++--- test/classes.ts | 144 ++++++++++++++++++++++++ 5 files changed, 505 insertions(+), 36 deletions(-) create mode 100644 .changeset/wicked-chefs-hope.md create mode 100644 test/classes.ts diff --git a/.changeset/wicked-chefs-hope.md b/.changeset/wicked-chefs-hope.md new file mode 100644 index 000000000..d2065148a --- /dev/null +++ b/.changeset/wicked-chefs-hope.md @@ -0,0 +1,5 @@ +--- +"@effect/schema": patch +--- + +Added `Class` to `Schema` module diff --git a/README.md b/README.md index 9eb29df5a..9c8d3edf0 100644 --- a/README.md +++ b/README.md @@ -910,6 +910,93 @@ encode({ a: O.none() }) // {} encode({ a: O.some(1) }) // { a: 1 } ``` +## Classes + +As an alternative to the `struct` constructor, you can create schemas as classes by extending the `Class` utility. + +They offer a few conveniences that can help with some common use cases: + +- define a schema and an opaque type in one pass +- attach common functionality using class methods or getters +- check equality by value and hashing (`Class` implements `Data.Case`) + +Take a look at the following example: + +```ts +import * as S from "@effect/schema/Schema"; + +// Define your schema by extending `Class` with the desired fields +class Person extends S.Class({ + id: S.number, + name: S.string +}) { + // Add getters and methods + get upperName() { + return this.name.toUpperCase(); + } +} + +// Extend an existing schema `Class` using the `extend` utility +class PersonWithAge extends Person.extend({ + age: S.number +}) { + get isAdult() { + return this.age >= 18; + } +} + +// You can use the class constructor to validate and then create a new instance from some properties +const tim = new Person({ id: 1, name: "Tim" }); + +// $ExpectType Schema<{ readonly id: number; name: string; }, Person> +Person.schema(); + +// $ExpectType Schema<{ readonly id: number; name: string; }, { readonly id: number; name: string; }> +Person.schemaStruct(); +``` + +#### Transforms + +You can enhance a class with (effectful) transforms. This can be useful if you want to embellish or validate an entity from a data store. + +```ts +import * as Effect from "@effect/io/Effect"; +import * as S from "@effect/schema/Schema"; +import * as O from "@effect/data/Option"; +import * as PR from "@effect/schema/ParseResult"; + +class Person extends S.Class({ + id: S.number, + name: S.string +}) + +function fetchThing(id: number): Effect.Effect { ... } + +class PersonWithTransform extends Person.transform( + { + thing: S.optional(S.string).toOption(), + }, + (input) => + Effect.mapBoth(fetchThing(input.id), { + onFailure: (e) => PR.parseError([PR.type(S.string, input, e.message)]), + onSuccess: (thing) => ({ ...input, thing: O.some(thing) }) + }), + PR.success +) {} + +class PersonWithTransformFrom extends Person.transformFrom( + { + thing: S.optional(S.string).toOption(), + }, + (input) => + Effect.mapBoth(fetchThing(input.id), { + onFailure: (e) => PR.parseError([PR.type(S.string, input, e.message)]), + onSuccess: (thing) => ({ ...input, thing }) + }), + PR.success +) {} +``` + ## Pick ```ts diff --git a/docs/modules/Schema.ts.md b/docs/modules/Schema.ts.md index c36eb079d..b788353bb 100644 --- a/docs/modules/Schema.ts.md +++ b/docs/modules/Schema.ts.md @@ -44,6 +44,9 @@ Added in v1.0.0 - [positiveBigint](#positivebigint) - [boolean](#boolean) - [not](#not) +- [classes](#classes) + - [Class](#class) + - [Class (interface)](#class-interface) - [combinators](#combinators) - [annotations](#annotations-1) - [array](#array-1) @@ -211,11 +214,14 @@ Added in v1.0.0 - [ValidDateTypeId](#validdatetypeid) - [utils](#utils) - [FromOptionalKeys (type alias)](#fromoptionalkeys-type-alias) + - [FromStruct (type alias)](#fromstruct-type-alias) - [Join (type alias)](#join-type-alias) - [PropertySignature (interface)](#propertysignature-interface) - [Spread (type alias)](#spread-type-alias) + - [StructFields (type alias)](#structfields-type-alias) - [ToAsserts](#toasserts) - [ToOptionalKeys (type alias)](#tooptionalkeys-type-alias) + - [ToStruct (type alias)](#tostruct-type-alias) - [from](#from) - [optional](#optional) - [to](#to) @@ -569,6 +575,55 @@ export declare const not: (self: Schema) => Schema Added in v1.0.0 +# classes + +## Class + +**Signature** + +```ts +export declare const Class: ( + fields: Fields +) => Class>, Spread>, {}> +``` + +Added in v1.0.0 + +## Class (interface) + +**Signature** + +```ts +export interface Class { + new (props: A): A & D.Case & Omit + + schema any>(this: T): Schema> + schemaStruct(): Schema + extend any, Fields extends StructFields>( + this: T, + fields: Fields + ): Class< + Spread, keyof Fields> & FromStruct>, + Spread, keyof Fields> & ToStruct>, + InstanceType + > + transform any, Fields extends StructFields>( + this: T, + fields: Fields, + decode: (input: Class.To) => ParseResult, keyof Fields> & ToStruct>, + encode: (input: Omit, keyof Fields> & ToStruct) => ParseResult> + ): Class, Spread, keyof Fields> & ToStruct>, InstanceType> + transformFrom any, Fields extends StructFields>( + this: T, + fields: Fields, + decode: (input: Class.From) => ParseResult, keyof Fields> & FromStruct>, + encode: (input: Omit, keyof Fields> & FromStruct) => ParseResult> + ): Class, Spread, keyof Fields> & ToStruct>, InstanceType> +} +``` + +Added in v1.0.0 + # combinators ## annotations @@ -989,28 +1044,9 @@ Added in v1.0.0 **Signature** ```ts -export declare const struct: < - Fields extends Record< - PropertyKey, - | Schema - | Schema - | PropertySignature - | PropertySignature - > ->( +export declare const struct: ( fields: Fields -) => Schema< - Spread< - { readonly [K in Exclude>]: From } & { - readonly [K in FromOptionalKeys]?: From | undefined - } - >, - Spread< - { readonly [K in Exclude>]: To } & { - readonly [K in ToOptionalKeys]?: To | undefined - } - > -> +) => Schema>, Spread>> ``` Added in v1.0.0 @@ -2463,6 +2499,18 @@ export type FromOptionalKeys = { Added in v1.0.0 +## FromStruct (type alias) + +**Signature** + +```ts +export type FromStruct = { + readonly [K in Exclude>]: From +} & { readonly [K in FromOptionalKeys]?: From } +``` + +Added in v1.0.0 + ## Join (type alias) **Signature** @@ -2507,6 +2555,22 @@ export type Spread = { Added in v1.0.0 +## StructFields (type alias) + +**Signature** + +```ts +export type StructFields = Record< + PropertyKey, + | Schema + | Schema + | PropertySignature + | PropertySignature +> +``` + +Added in v1.0.0 + ## ToAsserts **Signature** @@ -2533,6 +2597,18 @@ export type ToOptionalKeys = { Added in v1.0.0 +## ToStruct (type alias) + +**Signature** + +```ts +export type ToStruct = { + readonly [K in Exclude>]: To +} & { readonly [K in ToOptionalKeys]?: To } +``` + +Added in v1.0.0 + ## from **Signature** diff --git a/src/Schema.ts b/src/Schema.ts index 92dcecf27..cf1371580 100644 --- a/src/Schema.ts +++ b/src/Schema.ts @@ -568,29 +568,42 @@ export type FromOptionalKeys = { : never }[keyof Fields] +/** + * @since 1.0.0 + */ +export type StructFields = Record< + PropertyKey, + | Schema + | Schema + | PropertySignature + | PropertySignature +> + +/** + * @since 1.0.0 + */ +export type FromStruct = + & { readonly [K in Exclude>]: From } + & { readonly [K in FromOptionalKeys]?: From } + +/** + * @since 1.0.0 + */ +export type ToStruct = + & { readonly [K in Exclude>]: To } + & { readonly [K in ToOptionalKeys]?: To } + /** * @category combinators * @since 1.0.0 */ export const struct = < - Fields extends Record< - PropertyKey, - | Schema - | Schema - | PropertySignature - | PropertySignature - > + Fields extends StructFields >( fields: Fields ): Schema< - Spread< - & { readonly [K in Exclude>]: From } - & { readonly [K in FromOptionalKeys]?: From } - >, - Spread< - & { readonly [K in Exclude>]: To } - & { readonly [K in ToOptionalKeys]?: To } - > + Spread>, + Spread> > => { const ownKeys = I.ownKeys(fields) const propertySignatures: Array = [] @@ -1183,6 +1196,150 @@ export const documentation = (documentation: AST.DocumentationAnnotation) => (self: Schema): Schema => make(AST.setAnnotation(self.ast, AST.DocumentationAnnotationId, documentation)) +// --------------------------------------------- +// classes +// --------------------------------------------- + +/** + * @category classes + * @since 1.0.0 + */ +export interface Class { + new(props: A): A & D.Case & Omit + + schema any>(this: T): Schema> + schemaStruct(): Schema + extend< + T extends new(...args: any) => any, + Fields extends StructFields + >( + this: T, + fields: Fields + ): Class< + Spread, keyof Fields> & FromStruct>, + Spread, keyof Fields> & ToStruct>, + InstanceType + > + transform< + T extends new(...args: any) => any, + Fields extends StructFields + >( + this: T, + fields: Fields, + decode: ( + input: Class.To + ) => ParseResult, keyof Fields> & ToStruct>, + encode: ( + input: Omit, keyof Fields> & ToStruct + ) => ParseResult> + ): Class< + Class.From, + Spread, keyof Fields> & ToStruct>, + InstanceType + > + transformFrom< + T extends new(...args: any) => any, + Fields extends StructFields + >( + this: T, + fields: Fields, + decode: ( + input: Class.From + ) => ParseResult, keyof Fields> & FromStruct>, + encode: ( + input: Omit, keyof Fields> & FromStruct + ) => ParseResult> + ): Class< + Class.From, + Spread, keyof Fields> & ToStruct>, + InstanceType + > +} + +/** + * @since 1.0.0 + */ +export namespace Class { + /** + * @since 1.0.0 + */ + export type To = A extends Class ? T : never + + /** + * @since 1.0.0 + */ + export type From = A extends Class ? F : never +} + +const makeClass = (selfSchema: Schema, selfFields: StructFields, base: any) => { + const validator = P.validateSync(selfSchema) + + const fn = function(this: any, props: unknown) { + Object.assign(this, validator(props)) + } + fn.prototype = Object.create(base) + fn.schemaStruct = function schemaStruct() { + return selfSchema + } + fn.schema = function schema(this: any) { + return transform( + selfSchema, + instanceOf(this), + (input) => Object.assign(Object.create(this.prototype), input), + (input) => ({ ...(input as any) }) + ) + } + fn.extend = function extend(this: any, fields: any) { + const newFields = { ...selfFields, ...fields } + return makeClass( + struct(newFields), + newFields, + this.prototype + ) + } + fn.transform = function transform(this: any, fields: any, decode: any, encode: any) { + const newFields = { ...selfFields, ...fields } + return makeClass( + transformResult( + selfSchema, + to(struct(newFields)), + decode, + encode + ), + newFields, + this.prototype + ) + } + fn.transformFrom = function transform(this: any, fields: any, decode: any, encode: any) { + const newFields = { ...selfFields, ...fields } + return makeClass( + transformResult( + from(selfSchema), + struct(newFields), + decode, + encode + ), + newFields, + this.prototype + ) + } + + return fn as any +} + +/** + * @category classes + * @since 1.0.0 + */ +export const Class = < + Fields extends StructFields +>( + fields: Fields +): Class< + Spread>, + Spread> +> => makeClass(struct(fields), fields, D.Class.prototype) + // --------------------------------------------- // data // --------------------------------------------- diff --git a/test/classes.ts b/test/classes.ts new file mode 100644 index 000000000..b1a85ebbd --- /dev/null +++ b/test/classes.ts @@ -0,0 +1,144 @@ +import * as Data from "@effect/data/Data" +import * as Equal from "@effect/data/Equal" +import * as O from "@effect/data/Option" +import * as PR from "@effect/schema/ParseResult" +import * as S from "@effect/schema/Schema" +import * as Util from "@effect/schema/test/util" + +class Person extends S.Class({ + id: S.number, + name: S.string +}) { + get upperName() { + return this.name.toUpperCase() + } +} + +class PersonWithAge extends Person.extend({ + age: S.number +}) { + get isAdult() { + return this.age >= 18 + } +} + +class PersonWithNick extends PersonWithAge.extend({ + nick: S.string +}) {} + +class PersonWithTransform extends Person.transform( + { + thing: S.optional(S.struct({ id: S.number })).toOption() + }, + (input) => + PR.success({ + ...input, + thing: O.some({ id: 123 }) + }), + PR.success +) {} + +class PersonWithTransformFrom extends Person.transformFrom( + { + thing: S.optional(S.struct({ id: S.number })).toOption() + }, + (input) => + PR.success({ + ...input, + thing: { id: 123 } + }), + PR.success +) {} + +describe("Class", () => { + it("constructor", () => { + const person = new Person({ id: 1, name: "John" }) + assert(person.name === "John") + assert(person.upperName === "JOHN") + expectTypeOf(person.upperName).toEqualTypeOf("string") + }) + + it("schema", () => { + const person = S.parseSync(Person.schema())({ id: 1, name: "John" }) + assert(person.name === "John") + + const PersonFromSelf = S.to(Person.schema()) + Util.expectParseSuccess(PersonFromSelf, new Person({ id: 1, name: "John" })) + Util.expectParseFailure( + PersonFromSelf, + { id: 1, name: "John" }, + `Expected an instance of Person, actual {"id":1,"name":"John"}` + ) + }) + + it("extends", () => { + const person = S.parseSync(PersonWithAge.schema())({ + id: 1, + name: "John", + age: 30 + }) + assert(person.name === "John") + assert(person.age === 30) + assert(person.isAdult === true) + assert(person.upperName === "JOHN") + expectTypeOf(person.upperName).toEqualTypeOf("string") + }) + + it("extends extends", () => { + const person = S.parseSync(PersonWithNick.schema())({ + id: 1, + name: "John", + age: 30, + nick: "Joe" + }) + assert(person.age === 30) + assert(person.nick === "Joe") + }) + + it("extends error", () => { + expect(() => S.parseSync(PersonWithAge.schema())({ id: 1, name: "John" })).toThrowError( + new Error(`error(s) found +└─ ["age"] + └─ is missing`) + ) + }) + + it("Data.Class", () => { + const person = new Person({ id: 1, name: "John" }) + const personAge = new PersonWithAge({ id: 1, name: "John", age: 30 }) + assert(person instanceof Data.Class) + assert(personAge instanceof Data.Class) + + const person2 = new Person({ id: 1, name: "John" }) + assert(Equal.equals(person, person2)) + + const person3 = new Person({ id: 2, name: "John" }) + assert(!Equal.equals(person, person3)) + }) + + it("transform", () => { + const decode = S.decodeSync(PersonWithTransform.schema()) + const person = decode({ + id: 1, + name: "John" + }) + assert(person.id === 1) + assert(person.name === "John") + assert(O.isSome(person.thing) && person.thing.value.id === 123) + assert(person.upperName === "JOHN") + expectTypeOf(person.upperName).toEqualTypeOf("string") + }) + + it("transform from", () => { + const decode = S.decodeSync(PersonWithTransformFrom.schema()) + const person = decode({ + id: 1, + name: "John" + }) + assert(person.id === 1) + assert(person.name === "John") + assert(O.isSome(person.thing) && person.thing.value.id === 123) + assert(person.upperName === "JOHN") + expectTypeOf(person.upperName).toEqualTypeOf("string") + }) +})