valibot icon indicating copy to clipboard operation
valibot copied to clipboard

implement `v.partialBy(schema, v.exactOptional)` and `v.requiredBy(schema, v.nonNullish)` using HKTs

Open EskiMojo14 opened this issue 3 months ago • 16 comments

basic demonstration/explanation of HKTs:

interface HKT {
  // final args will be here
  rawArgs: unknown[];
  // fn can give a specific type for the args needed
  argConstraints: any[];
  // check that args match constraint
  args: this["rawArgs"] extends this["argConstraints"]
    ? this["rawArgs"]
    : never;

  // result will be here
  result: unknown;
}

type ApplyHKT<THKT extends HKT, TArgs extends THKT["argConstraints"]> = (THKT & {
  // important: when the interface uses `this`, it *includes* this intersection
  rawArgs: TArgs;
})["result"]; // get the result out

// function identity<T>(item: T) { return item }
interface IdentityHKT extends HKT {
  // args should be a single item tuple
  argConstraints: [item: unknown];
  // always return the first item
  result: this["args"][0];
}

// identity.apply(null, ["foo"])
type Test = ApplyHKT<IdentityHKT, ["foo"]>;
//   ^? "foo"

EskiMojo14 avatar Aug 17 '25 00:08 EskiMojo14

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
valibot Error Error Aug 23, 2025 10:53am

vercel[bot] avatar Aug 17 '25 00:08 vercel[bot]

Hey 👋 thanks for working on this! I would like to discuss the API before reviewing your code. What do you think about:

const Schema1 = v.make(v.optional, MyObjectSchema);
const Schema2 = v.make(v.nullable, MyObjectSchema);

It would be great if we could allow developers to pass default values (e.g. for optional) and custom error messages (e.g for nonOptional). However, I'm not sure if this is possible with a nice DX that matches our other APIs.

fabian-hiller avatar Aug 18 '25 10:08 fabian-hiller

yeah, I'm not sure how possible configuring the modifiers could be - I'll have a play around

it could be that something like v.make(Schema, (entrySchema) => v.optional(entrySchema, default)) could work - the HKT is derived from the function result after all

although i don't think it would be possible to infer separate default types for each schema cleanly

maybe a mapped type like

v.make(Schema, v.optional, { foo: fooDefault })

?

EskiMojo14 avatar Aug 18 '25 10:08 EskiMojo14

I like the function approach, and I think I know how to implement it correctly, if we limit the default value to null and undefined depending on the schema. However, we need to find a better name. make works great when the wrapper schema is the first argument, but when using a function, it should be the second argument.

fabian-hiller avatar Aug 18 '25 22:08 fabian-hiller

I've tested the object approach and it works well, it allows each key to correctly infer its configuration specifically

EskiMojo14 avatar Aug 18 '25 22:08 EskiMojo14

image image

EskiMojo14 avatar Aug 18 '25 22:08 EskiMojo14

A drawback with the object approach for the default values might be that it does not work for nonOptional, nonNullable and nonNullish. A workaround would be a second function for these schemas.

Another API idea could be:

const Schema = v.wrapObjectEntries(MyObjectSchema, {
  name: v.nullable,
  age: v.optional,
  email: (schema) => v.nullish(schema, 'default_value'),
});

The problem with this API is that it doesn't work well with large object schemas. I'm not sure, but I think most developers are looking for an easy way to wrap all entries with the same schema.

fabian-hiller avatar Aug 18 '25 22:08 fabian-hiller

I am not sure yet about the name but here a few names we could combine with ...Entries or ...ObjectEntries:

  • wrap
  • modify
  • refine
  • alter
  • map

fabian-hiller avatar Aug 18 '25 22:08 fabian-hiller

A drawback with the object approach for the default values might be that it does not work for nonOptional, nonNullable and nonNullish. A workaround would be a second function for these schemas.

Why not? The second argument would just be error messages instead of default values, since that's what they accept.

const Schema = v.wrapObjectEntries(MyObjectSchema, {
  name: v.nullable,
  age: v.optional,
  email: (schema) => v.nullish(schema, 'default_value'),
});

This is nice, but gets into diminishing returns vs manually modifying each key i feel

EskiMojo14 avatar Aug 18 '25 22:08 EskiMojo14

Do you have a favorite API and name right now?

fabian-hiller avatar Aug 18 '25 22:08 fabian-hiller

no preferences on name

i think the proposed modifier-per-key approach is nice for rare cases but clunky for more common cases

the map-per-argument approach I've pushed works quite nicely for inferring output, but it has some downsides in terms of DX - because it's inferring the entire array of arguments you don't get the visibility of what they should be ahead of time

EskiMojo14 avatar Aug 18 '25 22:08 EskiMojo14

wondering about an overloaded function, e.g.

v.mapEntries(Schema, v.optional) // make all optional
v.mapEntries(Schema, {
  name: v.nullable,
  age: v.optional,
  email: (schema) => v.nullish(schema, 'default_value'),
}) // manually specify each
// which would also allow the below (if we modified v.entriesFromList a little)
v.mapEntries(Schema, v.entriesFromList(["name", "age"], v.optional))
// maybe even allow
v.mapEntries(Schema, v.optional, { name: v.nullable }) // blanket with overrides

// i don't think this should be allowed though unfortunately
v.mapEntries(Schema, (schema) => v.optional(schema, 'default_value'))

EskiMojo14 avatar Aug 19 '25 08:08 EskiMojo14

For official APIs, I try to keep them as simple as possible by limiting the options for achieving the same result. Having multiple overload signatures can make using the function correctly complicated.

If we type the argument as <TEntry extends BaseSchema, TWrapper extends ...>(schema: TTSchema) => TWrapper it might be possible that users can write v.optional and (schema) => v.optional(schema, ...).

fabian-hiller avatar Aug 24 '25 15:08 fabian-hiller

right, but the point is that we can't properly infer from a user submitted "one fits all" mapper what each entry should look like. For example:

const userSchema = v.object({
  name: v.string(),
  age: v.number()
})

const withDefaults = v.mapEntries(userSchema, (schema) => v.optional(schema, schema.type === "string" ? "John" : 36))


// worst case (no HKTs, just use mapper result):
withDefaults.entries;
//           ^? Record<"name" | "age", OptionalSchema<StringSchema<undefined> | NumberSchema<undefined>, string | number>

// best case (mix HKTs and mapper result):
withDefaults.entries;
//           ^? { name: OptionalSchema<StringSchema<undefined>, string | number>; age: OptionalSchema<NumberSchema<undefined>, string | number>; }

We also can't guarantee that the default provided matches every schema properly, only at least one of them. For example:

// no error
const withDefaults = v.mapEntries(userSchema, (schema) => v.optional(schema, "John"))

EskiMojo14 avatar Aug 24 '25 16:08 EskiMojo14

That's correct. Perhaps we should focus on creating a simple API similar to partial with the only difference that it allows users to choose the wrapper schema. If users need complex modifications, it might be better to redefine the schema or make the modifications by hand. A complex API could make things more difficult for us to maintain and for others to use. What do you think?

fabian-hiller avatar Aug 27 '25 11:08 fabian-hiller

sure, so just the v.wrapEntries(userSchema, v.exactOptional) API?

EskiMojo14 avatar Aug 27 '25 13:08 EskiMojo14