effect
effect copied to clipboard
proposal for data constructor defaults
Related to Schema #2319 and issue #1997
⚠️ No Changeset found
Latest commit: 73bcfeda31f11e62861e9e6868ac71216eb2eca1
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
Click here to learn what changesets are, and how to add one.
Click here if you're a maintainer who wants to add a changeset to this PR
If we want to solve the problem of adding defaults to a generic constructor, I think we should consider both class constructors and "plain" constructors (such as Data.taggedEnum).
import * as S from "@effect/schema/Schema"
import * as assert from "assert"
import * as Data from "effect/Data"
import { dual } from "effect/Function"
import type * as Types from "effect/Types"
/**
* Represents a plain constructor function.
*/
export interface PlainConstructor<In extends Array<any>, Out> {
(...input: In): Out
}
/**
* Represents a class constructor function.
*/
export interface ClassConstructor<In extends Array<any>, Out> {
new(...input: In): Out
}
/**
* Maps the input of a plain constructor function using a mapper function.
*/
export const mapPlainInput = <In extends Array<any>, Out, In2 extends Array<any>>(
c: PlainConstructor<In, Out>,
f: (...i2: In2) => In
): PlainConstructor<In2, Out> =>
(...i2) => c(...f(...i2))
/**
* Maps the input of a class constructor function using a mapper function.
*/
export const mapClassInput = <In extends Array<any>, Out, In2 extends Array<any>>(
c: ClassConstructor<In, Out>,
f: (...input: In2) => In
): ClassConstructor<In2, Out> =>
class extends (c as any) {
constructor(...input: In2) {
super(...f(...input))
}
} as any
/**
* Makes specific properties of the original type optional.
*/
export type PartialInput<In, D extends keyof In> = Types.Simplify<
Omit<In, D> & { [K in D]?: In[K] }
>
/**
* A shape for expressing defaults
*/
export type Defaults<In> = { [K in keyof In]?: () => In[K] }
/**
* Adds default values to properties of an object.
*/
export const addDefaults: {
<In, D extends Defaults<In>>(
defaults: D
): (partialInput: PartialInput<In, keyof D & keyof In>) => In
<In, D extends Defaults<In>>(
partialInput: PartialInput<In, keyof D & keyof In>,
defaults: D
): In
} = dual(2, (
partialInput: object | undefined,
defaults: Record<string, () => unknown>
) => {
const out: Record<string, unknown> = { ...partialInput }
for (const k in defaults) {
if (!Object.prototype.hasOwnProperty.call(out, k)) {
out[k] = defaults[k]()
}
}
return out
})
/**
* Adds default values to properties of a plain constructor's input object.
*/
export const addPlainConstructorDefaults = <Head, Tail extends Array<any>, Out, D extends Defaults<Head>>(
plainConstructor: PlainConstructor<[Head, ...Tail], Out>,
defaults: D
): PlainConstructor<[PartialInput<Head, keyof D & keyof Head>, ...Tail], Out> =>
mapPlainInput(plainConstructor, (head, ...tail) => [addDefaults<Head, D>(head, defaults), ...tail] as const)
/**
* Adds default values to properties of a class constructor's input object.
*/
export const addClassConstructorDefaults = <Head, Tail extends Array<any>, Out, D extends Defaults<Head>>(
classConstructor: ClassConstructor<[Head, ...Tail], Out>,
defaults: D
): ClassConstructor<[PartialInput<Head, keyof D & keyof Head>, ...Tail], Out> =>
mapClassInput(classConstructor, (head, ...tail) => [addDefaults<Head, D>(head, defaults), ...tail] as const)
// --------------------- EXAMPLES -------------------------------
// ----------------------------------------------------
// plain constructor
// ----------------------------------------------------
const plainConstructor: PlainConstructor<[{ a: string; b: number }, boolean], { a: string; b: number }> = (a) => a
const plainConstructorWithDefaults = addPlainConstructorDefaults(plainConstructor, { a: () => "" })
export type plainConstructorWithDefaultsParameters = Parameters<typeof plainConstructorWithDefaults>
/*
type plainConstructorWithDefaultsParameters = [{
b: number;
a?: string;
}, boolean]
*/
assert.deepStrictEqual(plainConstructorWithDefaults({ b: 1 }, true), { b: 1, a: "" })
assert.deepStrictEqual(plainConstructorWithDefaults({ b: 1, a: "a" }, true), { b: 1, a: "a" })
// ----------------------------------------------------
// Data.taggedEnum
// ----------------------------------------------------
const ctors = Data.taggedEnum<
| { readonly _tag: "BadRequest"; readonly status: 400; readonly message: string }
| { readonly _tag: "NotFound"; readonly status: 404; readonly message: string }
>()
const BadRequest = addPlainConstructorDefaults(ctors.BadRequest, { status: () => 400 })
assert.deepStrictEqual({ ...BadRequest({ message: "a" }) }, { _tag: "BadRequest", message: "a", status: 400 })
// ----------------------------------------------------
// Data Class
// ----------------------------------------------------
// add defaults to a class constructor manually
class DataClassWithDefaultsManual extends Data.Class<{ a: string; b: number }> {
constructor(props: PartialInput<DataClassWithDefaultsManual, "a">) {
super(addDefaults(props, { a: () => "" }))
}
}
export type DataClassWithDefaultsManualParameters = ConstructorParameters<typeof DataClassWithDefaultsManual>
/*
type DataClassWithDefaultsManualParameters = [props: {
readonly b: number;
readonly a?: string;
}]
*/
assert.deepStrictEqual({ ...new DataClassWithDefaultsManual({ b: 1 }) }, { a: "", b: 1 })
assert.deepStrictEqual({ ...new DataClassWithDefaultsManual({ b: 1, a: "a" }) }, { a: "a", b: 1 })
class DataClassWithAllDefaultsManual extends Data.Class<{ a: string; b: number }> {
constructor(props: PartialInput<DataClassWithAllDefaultsManual, "a" | "b"> | void) {
super(addDefaults(props ?? {}, { a: () => "", b: () => 1 }))
}
}
assert.deepStrictEqual({ ...new DataClassWithAllDefaultsManual() }, { a: "", b: 1 })
// add defaults to a class constructor via combinator
const DataClassWithDefaultsCombinator = addClassConstructorDefaults(
class DataClass extends Data.Class<{ a: string; b: number }> {},
{
a: () => ""
}
)
export type DataClassWithDefaultsCombinatorParameters = ConstructorParameters<
typeof DataClassWithDefaultsCombinator
>
/*
type DataClassWithDefaultsCombinatorParameters = [{
readonly b: number;
readonly a?: string;
}]
*/
assert.deepStrictEqual({ ...new DataClassWithDefaultsCombinator({ b: 1 }) }, { a: "", b: 1 })
assert.deepStrictEqual({ ...new DataClassWithDefaultsCombinator({ b: 1, a: "a" }) }, { a: "a", b: 1 })
// ----------------------------------------------------
// Schema Class
// ----------------------------------------------------
// add defaults to a class constructor manually
class SchemaClassWithDefaultsManual extends S.Class<SchemaClassWithDefaultsManual>("Person")({
a: S.string,
b: S.number
}) {
constructor(
props: PartialInput<SchemaClassWithDefaultsManual, "a">,
disableValidation?: boolean
) {
super(addDefaults(props, { a: () => "" }), disableValidation)
}
}
export type SchemaClassWithDefaultsManualParameters = ConstructorParameters<typeof SchemaClassWithDefaultsManual>
/*
type SchemaClassWithDefaultsManualParameters = [props: {
readonly b: number;
readonly a?: string;
}, disableValidation?: boolean | undefined]
*/
assert.deepStrictEqual({ ...new SchemaClassWithDefaultsManual({ b: 1 }, true) }, { a: "", b: 1 })
assert.deepStrictEqual({ ...new SchemaClassWithDefaultsManual({ b: 1, a: "a" }, true) }, { a: "a", b: 1 })
// add defaults to a class constructor via combinator
const SchemaClassWithDefaultsCombinator = addClassConstructorDefaults(
class SchemaClass extends S.Class<SchemaClass>("Person")({
a: S.string,
b: S.number
}) {},
{
a: () => ""
}
)
export type SchemaClassWithDefaultsCombinatorParameters = ConstructorParameters<
typeof SchemaClassWithDefaultsCombinator
>
/*
type SchemaClassWithDefaultsCombinatorParameters = [{
readonly b: number;
readonly a?: string;
}, disableValidation?: boolean | undefined]
*/
assert.deepStrictEqual({ ...new SchemaClassWithDefaultsCombinator({ b: 1 }, true) }, { a: "", b: 1 })
assert.deepStrictEqual({ ...new SchemaClassWithDefaultsCombinator({ b: 1, a: "a" }, true) }, { a: "a", b: 1 })
@gcanti totally.
One thing to keep in mind is that in the Schema class example you are loosing the benefit of a class combined value and shape in one, as well as the name of the class which becomes anonymous, you're left with a const and need to add a type based on typeof or an interface manually.
I think it's better to wrap it around the S.Class as per my example.
@patroza do you mean
class SchemaClassWithDefaultsCombinatorClass extends addClassConstructorDefaults(
class SchemaClass extends S.Class<SchemaClass>("Person")({
a: S.string,
b: S.number
}) {},
{
a: () => ""
}
) {}
It doesn't work anyway
// ts error: Property 'ast' does not exist on type 'typeof SchemaClassWithDefaultsCombinatorClass'.ts(2339)
SchemaClassWithDefaultsCombinatorClass.ast
@gcanti no, it should work like
class TestError extends Data.addDefaults(
Data.TaggedError("TestError")<{ a: string; b: number }>,
{ b: () => 1 }
) {}
so
class SchemaClass extends addClassConstructorDefaults(S.Class<SchemaClass>("Person")({
a: S.string,
b: S.number
}),
{
a: () => ""
}
) {}
otherwise you have options like default property initialisers in the class, or using the manual approach. anyway, for Schema we have better options ;)
It doesn't work either, or at least not in my setup. Does it work for you?
class SchemaClass extends addClassConstructorDefaults(
S.Class<SchemaClass>("Person")({
a: S.string,
b: S.number
}),
{
a: () => ""
}
) {}
// Property 'ast' does not exist on type 'typeof SchemaClass'.ts(2339)
SchemaClass.ast
@gcanti I bet, but it's how it should work, imo. on the outside should be the public/exported class, not a const(+type/interface), and on the inside should be S.Class, Data.TaggedError etc, but also leaves open custom anonymous class:
class X extends addClassConstructorDefaults(
class extends S.Class.. {
.. some more custom stuff
}) {}
I don't see how it could ever work, in your combinator (and in my addClassConstructorDefaults above) only the constructor is handled:
export const addDefaults: <Args, Defaults extends { [K in keyof Args]?: () => Args[K] }, Out extends object>(
newable: new(args: Args) => Out,
defaults: Defaults
) => new(
args: Omit<Args, keyof Defaults> & { [K in keyof Args as K extends keyof Defaults ? K : never]?: Args[K] }
) => Out
all the rest that possibly defines the class (methods, static fields, etc...) is ignored and therefore is not inherited by the result (at the type-level).
Something like this seems to work
export type ClassConstructorHead<C> = C extends ClassConstructor<[infer Head], any> ? Head
: never
export type ClassConstructorTail<C> = C extends ClassConstructor<[any, ...infer Tail], any> ? Tail
: never
export type ClassConstructorOut<C> = C extends ClassConstructor<any, infer Out> ? Out : never
export declare const addClassConstructorDefaults2: <
C extends ClassConstructor<any, any>,
D extends Defaults<ClassConstructorHead<C>>
>(
classConstructor: C,
defaults: D
) =>
& C
& ClassConstructor<
[
PartialInput<ClassConstructorHead<C>, keyof D & keyof ClassConstructorHead<C>>,
...ClassConstructorTail<C>
],
ClassConstructorOut<C>
>
class SchemaClass extends addClassConstructorDefaults2(
S.Class<SchemaClass>("Person")({
a: S.string,
b: S.number
}),
{
a: () => ""
}
) {}
// OK
SchemaClass.ast
@gcanti you're right, that's an oversight in my proposal, which was only targeting the Data classes, and sadly only the instance side of the type, that is my bad.