Skip to content

Conversation

EskiMojo14
Copy link
Contributor

@EskiMojo14 EskiMojo14 commented Aug 17, 2025

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"

Copy link

vercel bot commented Aug 17, 2025

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

@EskiMojo14 EskiMojo14 changed the title experiment: use HKTs to enable v.partialBy(schema, v.exactOptional) implement v.partialBy(schema, v.exactOptional) and v.requiredBy(schema, v.nonNullish) using HKTs Aug 17, 2025
@fabian-hiller
Copy link
Owner

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.

@EskiMojo14
Copy link
Contributor Author

EskiMojo14 commented Aug 18, 2025

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

?

@fabian-hiller
Copy link
Owner

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.

@EskiMojo14
Copy link
Contributor Author

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

@EskiMojo14
Copy link
Contributor Author

image image

@fabian-hiller
Copy link
Owner

fabian-hiller commented Aug 18, 2025

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
Copy link
Owner

fabian-hiller commented Aug 18, 2025

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

@EskiMojo14
Copy link
Contributor Author

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

@fabian-hiller
Copy link
Owner

Do you have a favorite API and name right now?

@EskiMojo14
Copy link
Contributor Author

EskiMojo14 commented Aug 18, 2025

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
Copy link
Contributor Author

EskiMojo14 commented Aug 19, 2025

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

@fabian-hiller
Copy link
Owner

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, ...).

@EskiMojo14
Copy link
Contributor Author

EskiMojo14 commented Aug 24, 2025

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

@fabian-hiller
Copy link
Owner

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?

@EskiMojo14
Copy link
Contributor Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants