valibot
valibot copied to clipboard
implement `v.partialBy(schema, v.exactOptional)` and `v.requiredBy(schema, v.nonNullish)` using HKTs
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"
The latest updates on your projects. Learn more about Vercel for GitHub.
| Project | Deployment | Preview | Comments | Updated (UTC) |
|---|---|---|---|---|
| valibot | Aug 23, 2025 10:53am |
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.
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 })
?
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.
I've tested the object approach and it works well, it allows each key to correctly infer its configuration specifically
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.
I am not sure yet about the name but here a few names we could combine with ...Entries or ...ObjectEntries:
wrapmodifyrefinealtermap
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
Do you have a favorite API and name right now?
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
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'))
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, ...).
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"))
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?
sure, so just the v.wrapEntries(userSchema, v.exactOptional) API?