diff --git a/.drone.jsonnet b/.drone.jsonnet new file mode 100644 index 0000000..fefec26 --- /dev/null +++ b/.drone.jsonnet @@ -0,0 +1,110 @@ +local bun_image = "oven/bun:1"; +local node_image = "node:20"; + + +local install_step = { + name: "install", + image: bun_image, + commands: ["bun install --frozen-lockfile"], +}; + +local lint_step = { + name: "lint", + image: bun_image, + commands: ["bun lint:tsc"], +}; + +local build_step = { + name: "build", + image: bun_image, + commands: ["bun run build"], +}; + +local pack_step = { + name: "pack", + image: node_image, + commands: ["npm pack --dry-run"], +}; + +local publish_step = { + name: "publish", + image: node_image, + + environment: { + NPM_TOKEN: { from_secret: "npm_token" } + }, + + commands: [ + "npm set @thilawyn:registry https://git.jvalver.de/api/packages/thilawyn/npm/", + "npm config set -- //git.jvalver.de/api/packages/thilawyn/npm/:_authToken $NPM_TOKEN", + "npm publish", + ], +}; + + +[ + // Lint the whole project when not in master, not in a PR nor on a tag + { + kind: "pipeline", + type: "docker", + name: "lint", + + trigger: { + ref: { + exclude: [ + "refs/heads/master", + "refs/pull/**", + "refs/tags/**", + ] + } + }, + + steps: [ + install_step, + lint_step, + ], + }, + + // Build the package without publishing for pull requests + { + kind: "pipeline", + type: "docker", + name: "build", + + trigger: { + ref: { + include: ["refs/pull/**"] + } + }, + + steps: [ + install_step, + lint_step, + build_step, + pack_step, + ], + }, + + // Build and publish the package for master and tags + { + kind: "pipeline", + type: "docker", + name: "build-publish", + + trigger: { + ref: { + include: [ + "refs/heads/master", + "refs/tags/**", + ] + } + }, + + steps: [ + install_step, + lint_step, + build_step, + publish_step, + ], + }, +] diff --git a/.gitignore b/.gitignore index ceaea36..745264f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,3 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* - diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..93f962e --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@thilawyn:registry=https://git.jvalver.de/api/packages/thilawyn/npm/ diff --git a/README.md b/README.md index b731fa9..adb9b58 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# schemable-classes +# schemable-class -Create TypeScript classes out of Zod schemas \ No newline at end of file +Create TypeScript classes out of Zod schemas diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..4e23eb8 Binary files /dev/null and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..bb40fbe --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install.scopes] +"@thilawyn" = "https://git.jvalver.de/api/packages/thilawyn/npm/" diff --git a/package.json b/package.json new file mode 100644 index 0000000..547eb5f --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "@thilawyn/schemable-class", + "version": "0.1.0", + "type": "module", + "publishConfig": { + "registry": "https://git.jvalver.de/api/packages/thilawyn/npm/" + }, + "files": [ + "./dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/schemable.d.mts", + "default": "./dist/schemable.mjs" + }, + "require": { + "types": "./dist/schemable.d.cts", + "default": "./dist/schemable.cjs" + } + }, + "./jsonifiable": { + "import": { + "types": "./dist/jsonifiable.d.mts", + "default": "./dist/jsonifiable.mjs" + }, + "require": { + "types": "./dist/jsonifiable.d.cts", + "default": "./dist/jsonifiable.cjs" + } + } + }, + "scripts": { + "build": "rollup -c rollup.config.ts", + "lint:tsc": "tsc --noEmit", + "clean:cache": "rm -f tsconfig.tsbuildinfo", + "clean:dist": "rm -rf dist", + "clean:node": "rm -rf node_modules" + }, + "dependencies": { + "decimal.js": "^10.4.3", + "effect": "^2.0.0-next.62", + "lodash-es": "^4.17.21", + "type-fest": "^4.9.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "@types/lodash-es": "^4.17.12", + "bun-types": "latest", + "npm-check-updates": "^16.14.12", + "npm-sort": "^0.0.4", + "rollup": "^4.9.1", + "rollup-plugin-cleanup": "^3.2.1", + "rollup-plugin-ts": "^3.4.5", + "tsx": "^4.7.0", + "typescript": "^5.3.3" + } +} diff --git a/rollup.config.ts b/rollup.config.ts new file mode 100644 index 0000000..8625946 --- /dev/null +++ b/rollup.config.ts @@ -0,0 +1,43 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve" +import { defineConfig } from "rollup" +import cleanup from "rollup-plugin-cleanup" +import ts from "rollup-plugin-ts" +import pkg from "./package.json" assert { type: "json" } + + +export const createBundleConfig = ( + input: string, + name: keyof typeof pkg.exports, +) => + defineConfig({ + input, + + output: [ + { + file: pkg.exports[name].import.default, + format: "esm", + }, + { + file: pkg.exports[name].require.default, + format: "cjs", + }, + ], + + external: id => !/^[./]/.test(id), + + plugins: [ + nodeResolve(), + ts(), + + cleanup({ + comments: "jsdoc", + extensions: ["ts"], + }), + ], + }) + + +export default [ + createBundleConfig("src/index.ts", "."), + createBundleConfig("src/jsonifiable/index.ts", "./jsonifiable"), +] diff --git a/src/SchemableClass.ts b/src/SchemableClass.ts new file mode 100644 index 0000000..f85216c --- /dev/null +++ b/src/SchemableClass.ts @@ -0,0 +1,90 @@ +import { Class } from "type-fest" +import { z } from "zod" + + +/** + * Configuration for creating a schemable object with validation schemas. + * @template Values - The type representing the expected values. + * @template Input - The type representing the input values. + * @template SchemaT - The type representing the base validation schema. + * @template SchemaUnknownKeys - The type representing the unknown keys behavior in the base validation schema. + * @template SchemaCatchall - The type representing the catchall behavior in the base validation schema. + * @template SchemaWithDefaultValuesT - The type representing the validation schema with default values. + * @template SchemaWithDefaultValuesUnknownKeys - The type representing the unknown keys behavior in the validation schema with default values. + * @template SchemaWithDefaultValuesCatchall - The type representing the catchall behavior in the validation schema with default values. + */ +export type SchemableConfig< + Values extends {} = {}, + Input extends {} = {}, + + SchemaT extends z.ZodRawShape = z.ZodRawShape, + SchemaUnknownKeys extends z.UnknownKeysParam = z.UnknownKeysParam, + SchemaCatchall extends z.ZodTypeAny = z.ZodTypeAny, + + SchemaWithDefaultValuesT extends z.ZodRawShape = z.ZodRawShape, + SchemaWithDefaultValuesUnknownKeys extends z.UnknownKeysParam = z.UnknownKeysParam, + SchemaWithDefaultValuesCatchall extends z.ZodTypeAny = z.ZodTypeAny, +> = { + readonly values: Values + readonly input: Input + + readonly schema: z.ZodObject< + SchemaT, + SchemaUnknownKeys, + SchemaCatchall, + Values, + Values + > + + readonly schemaWithDefaultValues: z.ZodObject< + SchemaWithDefaultValuesT, + SchemaWithDefaultValuesUnknownKeys, + SchemaWithDefaultValuesCatchall, + Values, + Input + > +} + + +/** + * Represents a class with validation schemas. + * @template $Config - The configuration type for the schemable object. + */ +export type SchemableClass< + $Config extends SchemableConfig +> = ( + Class< + SchemableObject<$Config>, + SchemableClassConstructorParams<$Config> + > & { + readonly $schemableConfig: $Config + readonly schema: $Config["schema"] + readonly schemaWithDefaultValues: $Config["schemaWithDefaultValues"] + } +) + +/** + * Represents the constructor parameters for the schemable object class. + * @template $Config - The configuration type for the schemable object. + */ +export type SchemableClassConstructorParams< + $Config extends SchemableConfig +> = ( + Parameters< + (data: $Config["values"]) => void + > +) + +/** + * Represents an object with validation schemas. + * @template $Config - The configuration type for the schemable object. + */ +export type SchemableObject< + $Config extends SchemableConfig +> = ( + { + readonly $schemableConfig: $Config + readonly schema: $Config["schema"] + readonly schemaWithDefaultValues: $Config["schemaWithDefaultValues"] + } & $Config["values"] +) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..17d8b56 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from "./SchemableClass" +export * from "./makeSchemableClass" +export * from "./newSchemable" diff --git a/src/jsonifiable/JsonifiableSchemableClass.ts b/src/jsonifiable/JsonifiableSchemableClass.ts new file mode 100644 index 0000000..c0dc3c0 --- /dev/null +++ b/src/jsonifiable/JsonifiableSchemableClass.ts @@ -0,0 +1,66 @@ +import { Effect } from "effect" +import { Class } from "type-fest" +import { JsonifiableObject } from "type-fest/source/jsonifiable" +import { z } from "zod" +import { SchemableClassConstructorParams, SchemableConfig } from ".." + + +export type JsonifiableSchemableConfig< + $SchemableConfig extends SchemableConfig = SchemableConfig, + + JsonifiedValues extends JsonifiableObject = {}, + + JsonifySchemaT extends z.ZodRawShape = z.ZodRawShape, + JsonifySchemaUnknownKeys extends z.UnknownKeysParam = z.UnknownKeysParam, + JsonifySchemaCatchall extends z.ZodTypeAny = z.ZodTypeAny, + + DejsonifySchemaT extends z.ZodRawShape = z.ZodRawShape, + DejsonifySchemaUnknownKeys extends z.UnknownKeysParam = z.UnknownKeysParam, + DejsonifySchemaCatchall extends z.ZodTypeAny = z.ZodTypeAny, +> = { + readonly $schemableConfig: $SchemableConfig + + readonly jsonifiedValues: JsonifiedValues + + readonly jsonifySchema: z.ZodObject< + JsonifySchemaT, + JsonifySchemaUnknownKeys, + JsonifySchemaCatchall, + JsonifiedValues, + $SchemableConfig["values"] + > + + readonly dejsonifySchema: z.ZodObject< + DejsonifySchemaT, + DejsonifySchemaUnknownKeys, + DejsonifySchemaCatchall, + $SchemableConfig["values"], + JsonifiedValues + > +} + + +export type JsonifiableSchemableClass< + $Config extends JsonifiableSchemableConfig +> = ( + Class< + JsonifiableSchemableObject<$Config>, + SchemableClassConstructorParams<$Config["$schemableConfig"]> + > & { + readonly $jsonifiableSchemableConfig: $Config + readonly jsonifySchema: $Config["jsonifySchema"] + readonly dejsonifySchema: $Config["dejsonifySchema"] + } +) + +export type JsonifiableSchemableObject< + $Config extends JsonifiableSchemableConfig +> = { + readonly $jsonifiableSchemableConfig: $Config + readonly jsonifySchema: $Config["jsonifySchema"] + readonly dejsonifySchema: $Config["dejsonifySchema"] + + jsonify(): $Config["jsonifiedValues"] + jsonifyPromise(): Promise<$Config["jsonifiedValues"]> + jsonifyEffect(): Effect.Effect, $Config["jsonifiedValues"]> +} diff --git a/src/jsonifiable/dejsonifySchemable.ts b/src/jsonifiable/dejsonifySchemable.ts new file mode 100644 index 0000000..35ceafb --- /dev/null +++ b/src/jsonifiable/dejsonifySchemable.ts @@ -0,0 +1,47 @@ +import { Effect, pipe } from "effect" +import { z } from "zod" +import { JsonifiableSchemableClass, JsonifiableSchemableConfig } from "." +import { parseZodTypeEffect } from "../util" + + +export const dejsonifySchemable = < + C extends JsonifiableSchemableClass<$Config>, + $Config extends JsonifiableSchemableConfig, +>( + class_: C | JsonifiableSchemableClass<$Config>, + values: $Config["jsonifiedValues"], + params?: Partial, +) => + new class_(class_.dejsonifySchema.parse(values, params)) as InstanceType + + +export const dejsonifySchemablePromise = async < + C extends JsonifiableSchemableClass<$Config>, + $Config extends JsonifiableSchemableConfig, +>( + class_: C | JsonifiableSchemableClass<$Config>, + values: $Config["jsonifiedValues"], + params?: Partial, +) => + new class_(await class_.dejsonifySchema.parseAsync(values, params)) as InstanceType + + +export const dejsonifySchemableEffect = < + C extends JsonifiableSchemableClass<$Config>, + $Config extends JsonifiableSchemableConfig, +>( + class_: C | JsonifiableSchemableClass<$Config>, + values: $Config["jsonifiedValues"], + params?: Partial, +) => pipe( + parseZodTypeEffect< + z.output, + z.input + >( + class_.dejsonifySchema, + values, + params, + ), + + Effect.map(values => new class_(values) as InstanceType), +) diff --git a/src/jsonifiable/index.ts b/src/jsonifiable/index.ts new file mode 100644 index 0000000..74e7d23 --- /dev/null +++ b/src/jsonifiable/index.ts @@ -0,0 +1,4 @@ +export * from "./JsonifiableSchemableClass" +export * from "./dejsonifySchemable" +export * from "./makeJsonifiableSchemableClass" +export * from "./schema" diff --git a/src/jsonifiable/makeJsonifiableSchemableClass.ts b/src/jsonifiable/makeJsonifiableSchemableClass.ts new file mode 100644 index 0000000..a956b0b --- /dev/null +++ b/src/jsonifiable/makeJsonifiableSchemableClass.ts @@ -0,0 +1,98 @@ +import { Class } from "type-fest" +import { JsonifiableObject } from "type-fest/source/jsonifiable" +import { z } from "zod" +import { JsonifiableSchemableClass, JsonifiableSchemableConfig, JsonifiableSchemableObject } from "." +import { SchemableClass, SchemableConfig } from ".." +import { StaticMembers, parseZodTypeEffect } from "../util" + + +export function makeJsonifiableSchemableClass< + C extends SchemableClass<$SchemableConfig>, + $SchemableConfig extends SchemableConfig, + + JsonifiedValues extends JsonifiableObject, + + JsonifySchemaT extends z.ZodRawShape, + JsonifySchemaUnknownKeys extends z.UnknownKeysParam, + JsonifySchemaCatchall extends z.ZodTypeAny, + + DejsonifySchemaT extends z.ZodRawShape, + DejsonifySchemaUnknownKeys extends z.UnknownKeysParam, + DejsonifySchemaCatchall extends z.ZodTypeAny, +>( + class_: C | SchemableClass<$SchemableConfig>, + + props: { + jsonifySchema: (props: { + schema: $SchemableConfig["schema"] + s: $SchemableConfig["schema"]["shape"] + }) => z.ZodObject< + JsonifySchemaT, + JsonifySchemaUnknownKeys, + JsonifySchemaCatchall, + JsonifiedValues, + $SchemableConfig["values"] + > + + dejsonifySchema: (props: { + schema: $SchemableConfig["schema"] + s: $SchemableConfig["schema"]["shape"] + }) => z.ZodObject< + DejsonifySchemaT, + DejsonifySchemaUnknownKeys, + DejsonifySchemaCatchall, + $SchemableConfig["values"], + JsonifiedValues + > + }, +) { + + const jsonifySchema = props.jsonifySchema({ + schema: class_.schema, + s: class_.schema.shape, + }) + + const dejsonifySchema = props.dejsonifySchema({ + schema: class_.schema, + s: class_.schema.shape, + }) + + const $jsonifiableSchemableConfig = { + $schemableConfig: class_.$schemableConfig, + jsonifiedValues: undefined as unknown as JsonifiedValues, + jsonifySchema: undefined as unknown as typeof jsonifySchema, + dejsonifySchema: undefined as unknown as typeof dejsonifySchema, + } as const satisfies JsonifiableSchemableConfig + + const jsonifiableClass = class JsonifiableSchemableObject extends class_ { + static readonly $jsonifiableSchemableConfig = $jsonifiableSchemableConfig + static readonly jsonifySchema = jsonifySchema + static readonly dejsonifySchema = dejsonifySchema + + readonly $jsonifiableSchemableConfig = $jsonifiableSchemableConfig + readonly jsonifySchema = jsonifySchema + readonly dejsonifySchema = dejsonifySchema + + jsonify() { + return this.jsonifySchema.parse(this) + } + + jsonifyPromise() { + return this.jsonifySchema.parseAsync(this) + } + + jsonifyEffect() { + return parseZodTypeEffect(this.jsonifySchema, this) + } + } satisfies JsonifiableSchemableClass + + return jsonifiableClass as unknown as ( + Class< + InstanceType & JsonifiableSchemableObject, + ConstructorParameters + > & + StaticMembers & + StaticMembers> + ) + +} diff --git a/src/jsonifiable/schema/bigint.ts b/src/jsonifiable/schema/bigint.ts new file mode 100644 index 0000000..b0f0093 --- /dev/null +++ b/src/jsonifiable/schema/bigint.ts @@ -0,0 +1,18 @@ +import { z } from "zod" + + +export const jsonifyBigIntSchema = (schema: S) => + schema.transform(v => v.toString()) + +export const dejsonifyBigIntSchema = (schema: S) => + z + .string() + .transform(v => { + try { + return BigInt(v) + } + catch (e) { + return v + } + }) + .pipe(schema) diff --git a/src/jsonifiable/schema/date.ts b/src/jsonifiable/schema/date.ts new file mode 100644 index 0000000..b5ac677 --- /dev/null +++ b/src/jsonifiable/schema/date.ts @@ -0,0 +1,18 @@ +import { z } from "zod" + + +export const jsonifyDateSchema = (schema: S) => + schema.transform(v => v.toString()) + +export const dejsonifyDateSchema = (schema: S) => + z + .string() + .transform(v => { + try { + return new Date(v) + } + catch (e) { + return v + } + }) + .pipe(schema) diff --git a/src/jsonifiable/schema/decimal.ts b/src/jsonifiable/schema/decimal.ts new file mode 100644 index 0000000..58c492d --- /dev/null +++ b/src/jsonifiable/schema/decimal.ts @@ -0,0 +1,19 @@ +import { Decimal } from "decimal.js" +import { z } from "zod" + + +export const jsonifyDecimalSchema = >(schema: S) => + schema.transform(v => v.toJSON()) + +export const dejsonifyDecimalSchema = >(schema: S) => + z + .string() + .transform(v => { + try { + return new Decimal(v) + } + catch (e) { + return v + } + }) + .pipe(schema) diff --git a/src/jsonifiable/schema/index.ts b/src/jsonifiable/schema/index.ts new file mode 100644 index 0000000..08ecef8 --- /dev/null +++ b/src/jsonifiable/schema/index.ts @@ -0,0 +1,4 @@ +export * from "./bigint" +export * from "./date" +export * from "./decimal" +export * from "./schemable" diff --git a/src/jsonifiable/schema/schemable.ts b/src/jsonifiable/schema/schemable.ts new file mode 100644 index 0000000..c6e2a2d --- /dev/null +++ b/src/jsonifiable/schema/schemable.ts @@ -0,0 +1,25 @@ +import { z } from "zod" +import { JsonifiableSchemableClass, JsonifiableSchemableConfig } from ".." + + +// TODO: try to find a way to get rid of the 'class_' arg +export const jsonifySchemableSchema = < + C extends JsonifiableSchemableClass<$Config>, + $Config extends JsonifiableSchemableConfig, + S extends z.ZodType, z.ZodTypeDef, InstanceType>, +>( + class_: C | JsonifiableSchemableClass<$Config>, + schema: S, +) => + schema.pipe(class_.jsonifySchema) + +// TODO: try to find a way to get rid of the 'class_' arg +export const dejsonifySchemableSchema = < + C extends JsonifiableSchemableClass<$Config>, + $Config extends JsonifiableSchemableConfig, + S extends z.ZodType, z.ZodTypeDef, InstanceType>, +>( + class_: C | JsonifiableSchemableClass<$Config>, + schema: S, +) => + class_.dejsonifySchema.transform(v => new class_(v)).pipe(schema) diff --git a/src/makeSchemableClass.ts b/src/makeSchemableClass.ts new file mode 100644 index 0000000..88bcf72 --- /dev/null +++ b/src/makeSchemableClass.ts @@ -0,0 +1,49 @@ +import { z } from "zod" +import { SchemableClass, SchemableConfig } from "." +import { zodObjectRemoveDefaults } from "./util" + + +export function makeSchemableClass< + SchemaWithDefaultValuesT extends z.ZodRawShape, + SchemaWithDefaultValuesUnknownKeys extends z.UnknownKeysParam, + SchemaWithDefaultValuesCatchall extends z.ZodTypeAny, + SchemaWithDefaultValuesOutput extends SchemaWithDefaultValuesInput, // TODO: apply "StripSchemaInputDefaults"? + SchemaWithDefaultValuesInput extends {}, +>( + { + schema: schemaWithDefaultValues + }: { + schema: z.ZodObject< + SchemaWithDefaultValuesT, + SchemaWithDefaultValuesUnknownKeys, + SchemaWithDefaultValuesCatchall, + SchemaWithDefaultValuesOutput, + SchemaWithDefaultValuesInput + > + } +) { + + const schema = zodObjectRemoveDefaults(schemaWithDefaultValues) + + const $schemableConfig = { + values: undefined as unknown as z.output, + input: undefined as unknown as z.input, + schema: undefined as unknown as typeof schema, + schemaWithDefaultValues: undefined as unknown as typeof schemaWithDefaultValues, + } as const satisfies SchemableConfig + + return class SchemableObject { + static readonly $schemableConfig = $schemableConfig + static readonly schema = schema + static readonly schemaWithDefaultValues = schemaWithDefaultValues + + readonly $schemableConfig = $schemableConfig + readonly schema = schema + readonly schemaWithDefaultValues = schemaWithDefaultValues + + constructor(data: z.output) { + Object.assign(this, data) + } + } as SchemableClass + +} diff --git a/src/newSchemable.ts b/src/newSchemable.ts new file mode 100644 index 0000000..da0be36 --- /dev/null +++ b/src/newSchemable.ts @@ -0,0 +1,77 @@ +import { Effect, pipe } from "effect" +import { HasRequiredKeys } from "type-fest" +import { z } from "zod" +import { SchemableClass, SchemableConfig } from "." +import { parseZodTypeEffect } from "./util" + + +type ParamsArgs = [] | [Partial] + +type NewSchemableArgs = + HasRequiredKeys extends true + ? [Input, ...ParamsArgs] + : [] | [Input, ...ParamsArgs] + + +/** + * Creates a new instance of a SchemableClass with default values. + * + * @param class_ - The SchemableClass. + * @param values - The values to be parsed and used to create the instance. + * @param params - Optional parameters for parsing. + * @returns A new instance of the specified SchemableClass. + */ +export const newSchemable = < + C extends SchemableClass<$Config>, + $Config extends SchemableConfig, +>( + class_: C | SchemableClass<$Config>, + ...[values, params]: NewSchemableArgs<$Config["input"]> +) => + new class_(class_.schemaWithDefaultValues.parse(values || {}, params)) as InstanceType + + +/** + * Creates a new instance of a SchemableClass with default values asynchronously. + * + * @param class_ - The SchemableClass. + * @param values - The values to be parsed and used to create the instance. + * @param params - Optional parameters for parsing. + * @returns A Promise resolving to a new instance of the specified SchemableClass. + */ +export const newSchemablePromise = async < + C extends SchemableClass<$Config>, + $Config extends SchemableConfig, +>( + class_: C | SchemableClass<$Config>, + ...[values, params]: NewSchemableArgs<$Config["input"]> +) => + new class_(await class_.schemaWithDefaultValues.parseAsync(values || {}, params)) as InstanceType + + +/** + * Creates a new instance of a SchemableClass with default values as an Effect. + * + * @param class_ - The SchemableClass. + * @param values - The values to be parsed and used to create the instance. + * @param params - Optional parameters for parsing. + * @returns An Effect producing a new instance of the specified SchemableClass. + */ +export const newSchemableEffect = < + C extends SchemableClass<$Config>, + $Config extends SchemableConfig, +>( + class_: C | SchemableClass<$Config>, + ...[values, params]: NewSchemableArgs<$Config["input"]> +) => pipe( + parseZodTypeEffect< + z.output, + z.input + >( + class_.schemaWithDefaultValues, + values || {}, + params, + ), + + Effect.map(values => new class_(values) as InstanceType), +) diff --git a/src/tests.ts b/src/tests.ts new file mode 100644 index 0000000..c19daf7 --- /dev/null +++ b/src/tests.ts @@ -0,0 +1,64 @@ +import { z } from "zod" +import { makeSchemableClass, newSchemable } from "." +import { dejsonifyBigIntSchema, dejsonifySchemable, dejsonifySchemableSchema, jsonifyBigIntSchema, jsonifySchemableSchema, makeJsonifiableSchemableClass } from "./jsonifiable" + + +const GroupSchema = z.object({ + /** Group ID */ + id: z.bigint(), + + /** Group name */ + name: z.string(), +}) + +const GroupSchemableObject = makeSchemableClass({ schema: GroupSchema }) + +const GroupJsonifiableSchemableObject = makeJsonifiableSchemableClass(GroupSchemableObject, { + jsonifySchema: ({ schema, s }) => schema.extend({ + id: jsonifyBigIntSchema(s.id) + }), + + dejsonifySchema: ({ schema, s }) => schema.extend({ + id: dejsonifyBigIntSchema(s.id) + }), +}) + +class Group extends GroupJsonifiableSchemableObject {} + + +const UserSchema = z.object({ + /** User ID */ + id: z.bigint(), + + /** Name string */ + name: z.string(), + + /** User group */ + group: z.instanceof(Group), +}) + +const UserSchemableObject = makeSchemableClass({ schema: UserSchema }) + +const UserJsonifiableSchemableObject = makeJsonifiableSchemableClass(UserSchemableObject, { + jsonifySchema: ({ schema, s }) => schema.extend({ + id: jsonifyBigIntSchema(s.id), + group: jsonifySchemableSchema(Group, s.group), + }), + + dejsonifySchema: ({ schema, s }) => schema.extend({ + id: dejsonifyBigIntSchema(s.id), + group: dejsonifySchemableSchema(Group, s.group), + }), +}) + +class User extends UserJsonifiableSchemableObject {} + + +const group1 = new Group({ id: 1n, name: "Group 1" }) + +const user1 = new User({ id: 1n, name: "User 1", group: group1 }) +const user2 = newSchemable(User, { id: 2n, name: "User 2", group: group1 }) + +const jsonifiedUser2 = user2.jsonify() +const dejsonifiedUser2 = dejsonifySchemable(User, jsonifiedUser2) +console.log(dejsonifiedUser2) diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..2cf7b08 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,82 @@ +import { Effect, pipe } from "effect" +import { mapValues } from "lodash-es" +import { z } from "zod" + + +/** + * Represents the static members of a class. + * @template C - The class type. + */ +export type StaticMembers = { + [Key in keyof C as Key extends "prototype" ? never : Key]: C[Key] +} + + +/** + * Removes default values from a ZodObject schema and returns a new schema. + * + * @param schema - The ZodObject schema to process. + * @returns A new ZodObject schema with default values removed. + */ +export const zodObjectRemoveDefaults = < + T extends z.ZodRawShape, + UnknownKeys extends z.UnknownKeysParam, + Catchall extends z.ZodTypeAny, + Output extends {}, + Input extends {}, +>( + schema: z.ZodObject< + T, + UnknownKeys, + Catchall, + Output, + Input + > +) => + schema.extend(zodShapeRemoveDefaults(schema.shape)) + +/** + * Removes default values from a ZodObject shape and returns a new shape. + * + * @param shape - The ZodObject shape to process. + * @returns A new shape with default values removed. + */ +export const zodShapeRemoveDefaults = < + Shape extends z.ZodRawShape +>( + shape: Shape +): { + [K in keyof Shape]: + Shape[K] extends z.ZodDefault + ? T + : Shape[K] +} => + mapValues(shape, el => + el instanceof z.ZodDefault + ? el.removeDefault() + : el + ) + + +/** + * Parses a value using a ZodType schema wrapped in an Effect monad. + * + * @param schema - The ZodType schema to use for parsing. + * @param args - The arguments to pass to the `safeParseAsync` method of the schema. + * @returns An Effect monad representing the parsing result. + */ +export const parseZodTypeEffect = < + Output, + Input, +>( + schema: z.ZodType, + ...args: Parameters +) => pipe( + Effect.promise(() => schema.safeParseAsync(...args)), + + Effect.flatMap(response => + response.success + ? Effect.succeed(response.data) + : Effect.fail(response.error) + ), +) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5470793 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + // "allowImportingTsExtensions": true, + // "noEmit": true, + "declaration": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "types": [ + "bun-types" + ] + }, + "include": ["src"] +}