Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal for data constructor defaults #2443

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

patroza
Copy link
Member

@patroza patroza commented Mar 30, 2024

Related to Schema #2319
and issue #1997

Copy link

changeset-bot bot commented Mar 30, 2024

⚠️ No Changeset found

Latest commit: 73bcfed

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

@gcanti
Copy link
Contributor

gcanti commented Apr 3, 2024

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

@patroza
Copy link
Member Author

patroza commented Apr 4, 2024

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

@gcanti
Copy link
Contributor

gcanti commented Apr 4, 2024

@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

@patroza
Copy link
Member Author

patroza commented Apr 4, 2024

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

@gcanti
Copy link
Contributor

gcanti commented Apr 4, 2024

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

@patroza
Copy link
Member Author

patroza commented Apr 4, 2024

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

@gcanti
Copy link
Contributor

gcanti commented Apr 4, 2024

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

@patroza
Copy link
Member Author

patroza commented Apr 4, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Discussion Ongoing
Development

Successfully merging this pull request may close these issues.

None yet

2 participants