effect icon indicating copy to clipboard operation
effect copied to clipboard

proposal for data constructor defaults

Open patroza opened this issue 1 year ago • 9 comments

Related to Schema #2319 and issue #1997

patroza avatar Mar 30 '24 09:03 patroza

⚠️ 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

changeset-bot[bot] avatar Mar 30 '24 09:03 changeset-bot[bot]

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 avatar Apr 03 '24 16:04 gcanti

@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 avatar Apr 04 '24 12:04 patroza

@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 avatar Apr 04 '24 14:04 gcanti

@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 ;)

patroza avatar Apr 04 '24 14:04 patroza

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 avatar Apr 04 '24 14:04 gcanti

@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
  }) {}

patroza avatar Apr 04 '24 14:04 patroza

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 avatar Apr 04 '24 15:04 gcanti

@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.

patroza avatar Apr 04 '24 15:04 patroza