Should we change Valibot's API? #463
Replies: 24 comments 82 replies
-
definetely in favor of something like this, the current api feels very limiting and hard to read once you go past the simple cases |
Beta Was this translation helpful? Give feedback.
-
This mental model is so much easier to reason about. Using valibot becomes more flat that way. I like the he term pipe. The nesting was very confusing to me. That's a go! For me. :-) |
Beta Was this translation helpful? Give feedback.
-
I think a non-modular // With the new `pipe` arguments
const LengthSchema = optional(string(), [
transform((input) => input.length),
brand('Length'),
])
// With Valibot's current API
const LengthSchema = brand(
transform(optional(string(), ''), (input) => input.length),
'Length'
) // With the new `pipe` arguments
const NumberSchema = string(
[toTrimmed(), decimal(), transform(parseInt)]
);
// Maybe we can add a new `from` method to ensure that NumberSchema is number().
const NumberSchema = number(
[from(string(toTrimmed(), decimal()), parseInt)]
);
// With Valibot's current API
const NumberSchema = transform(
string([toTrimmed(), decimal()]),
parseInt
); // With the new `pipe` arguments
const LoginSchema = object({
email: string([message("Email"), minLength(1), email()]),
password: string([minLength(8)]),
});
// With Valibot's current API
const LoginSchema = object({
email: string("Wrong Email", [minLength(1), email()]),
password: string([minLength(8)]),
}); |
Beta Was this translation helpful? Give feedback.
-
Would the pipe function replace all pipe arguments, including forward() on object schemas? |
Beta Was this translation helpful? Give feedback.
-
Valibot might make the code a bit harder to read because of nested calls, but that's what helps keep the bundle size small. On the other hand, Zod.js has a cleaner design but comes with a bigger bundle size. So, it's a tradeoff to consider If we can design an API that looks good and keeps the bundle size small, Valibot would be the winner. It's all about having a nice API design without sacrificing bundle size. let's think |
Beta Was this translation helpful? Give feedback.
-
I prefer this new API. https://gcanti.github.io/fp-ts/modules/function.ts.html#flow |
Beta Was this translation helpful? Give feedback.
-
I really like the pipe function, it simplifies the API, string and min aren't different anymore, they are just validators on that attribute, it just makes sense, and if it adds benefits in terms of bundle that is win win for me |
Beta Was this translation helpful? Give feedback.
-
I like the new proposal. In particular the advantage of transforming data types. But my gut doesn't like those repeated |
Beta Was this translation helpful? Give feedback.
-
Alternative names for
|
Beta Was this translation helpful? Give feedback.
-
I like new API. It's so much easier to read and understand. |
Beta Was this translation helpful? Give feedback.
-
Would it be possible to include a |
Beta Was this translation helpful? Give feedback.
-
will this have any impact on #176? |
Beta Was this translation helpful? Give feedback.
-
The choice of terminology such as Refs:
However, there are also notable exceptions. Both |
Beta Was this translation helpful? Give feedback.
-
We have to also consider the implementation of types as well. In the current design, the input types of each member in the pipe argument are always the same so we can use a single generic parameter to enforce them all. /**
* Pipe type.
*/
type Pipe<TInput> = (BaseValidation<TInput> | BaseTransformation<TInput>)[]; With the new API, as suggested in the post, the data type can be changed. I'm not sure if there's a simple solution to correctly implement the type of this new API. Existing libraries in typescript like |
Beta Was this translation helpful? Give feedback.
-
Thanks for your work on Valibot so far Fabian. The reason I am drawn to Valibot over Zod is its cleaner API. Zod adds a lot more boilerplate than perhaps is necessary. Composable functions like string(), url(), email() coerce() just read so much cleaner. This proposal is pretty exciting for making the DSL of Valibot even more consistent and intuitive. I'm a huge fan of the functional programming style of being able to build pipelines of composable functions that can then be executed to validate inputs. As proposed this change will remove the repetition of the arguments currently passed to schema methods and instead provide a consistent, reliable and memorisable framework for validation. My main thought reading the proposal and seeing the poll on X was that pipe() is a functional programming term and pretty meaningless to validation. I’ve always liked the Rails Active Record Validations for an example of a lib that focuses more on a clean and readable API. As a developer, I want to be able to read a validation schema and it make sense in plain english. The closer we get to plain english, and having even non-technical people read and understand it, the better. Adding a whole bunch of I'm not completely happy with any of my suggestions below, but here they are anyway: Suggestion 1Would it be cleaner to use arrays to define pipelines in objects instead? This proposal is essentially renaming pipe to schema, which has already been suggest, plus using arrays as the values in object() definitions. // Instead of this:
const LoginSchema = object({
email: pipe(string(), minLength(1), email()),
password: pipe(string(), minLength(8)),
});
// With arrays:
const LoginSchema = object({
email: [string(), minLength(1), email()],
password: [string(), minLength(8)],
});
// Instead of this:
const UserSchema = pipe(
object({
id: pipe(string(), uuid(), primaryKey()),
name: pipe(string(), maxLength(32), unique()),
bio: pipe(string(), description('Text ...')),
}),
table('users')
);
// With schema wrapper:
const UserSchema = schema(
object({
id: [string(), uuid(), primaryKey()],
name: [string(), maxLength(32), unique()],
bio: [string(), description('Text ...')],
}),
table('users')
);
// Instead of:
const NumberSchema = pipe(union([strDecimal, numInteger]), transform(Number));
// With schema wrapper:
const NumberSchema = schema(union([strDecimal, numInteger]), transform(Number)); Suggestion 2Standardise around a schema function with takes a spread of arguments that are either objects or composable functions. // Instead of this:
const LoginSchema = object({
email: pipe(string(), minLength(1), email()),
password: pipe(string(), minLength(8)),
});
// With object passed to schema function:
const LoginSchema = schema({
email: [string(), minLength(1), email()],
password: [string(), minLength(8)],
});
// Instead of this:
const UserSchema = pipe(
object({
id: pipe(string(), uuid(), primaryKey()),
name: pipe(string(), maxLength(32), unique()),
bio: pipe(string(), description('Text ...')),
}),
table('users')
);
// With schema wrapper:
const UserSchema = schema(
{
id: [string(), uuid(), primaryKey()],
name: [string(), maxLength(32), unique()],
bio: [string(), description('Text ...')],
},
table('users')
);
// Instead of:
const NumberSchema = pipe(union([strDecimal, numInteger]), transform(Number));
// With schema wrapper:
const NumberSchema = schema(union([strDecimal, numInteger]), transform(Number)); Even with |
Beta Was this translation helpful? Give feedback.
-
I'm on board with Though I am curious how the current approach compares to |
Beta Was this translation helpful? Give feedback.
-
This is a positive direction. I consider your lib a FP style library. So adding a composition function is the logical extension of this. Every FP style library provides one. What would be even better though is if your functions work with FP libraries that already provide utility functions like The benefit is that you only need to provide a basic Another suggestion is that brackets should be removed for functions without terms // current proposal
const LoginSchema = object({
email: pipe(string(), minLength(1), email()),
password: pipe(string(), minLength(8)),
});
// my suggestion
const LoginSchema = object({
email: pipe(string, minLength(1), email),
password: pipe(string, minLength(8)),
}); ReferenceRamda pipeimport R from 'ramda'
// const f = (x,y) => R.add(R.negate(R.Math.pow(x,y)))
const f = R.pipe(Math.pow, R.negate, R.add(2));
f(3, 4); // -(3^4) + 2 FP-TS flowimport { flow } from 'fp-ts/lib/function'
// const f = (x) => toString(multiply2(add1(x)))
const f = flow(add1, multiply2, toString) // this is equivalent |
Beta Was this translation helpful? Give feedback.
-
I am not a very experienced developer, especially in matters of validation and listing architecture. it may be worth considering alternative writing options: As a result: the new version looks clearer and easier to read. It looks a little less convenient, with a large scheme, for reading (I already said that the constant pipe() will make your eyes blink). But definitely this way looks right. It is a pity that all the schemes will have to be redone after the introduction of this innovation, if I understood correctly. |
Beta Was this translation helpful? Give feedback.
-
I think the pipe function is very good. If I were to make a minor request, I would prefer to avoid nesting functions by using the pipe function, rather than nesting calls, even when defining optionals. However, this approach might be at a disadvantage in terms of implementation size or performance. // I prefer this
const OptionalStr = pipe(string(), optional('foo'))
// over this
const OptionalStr = optional(string(), 'foo') |
Beta Was this translation helpful? Give feedback.
-
I like this new DX suggestions / discussions where everyone can pour out their thoughts over as the movement will affect the library moving on as well. I like the pipe() here as well, it is easier to do transform and simpler in the mental model. Need to give more complex schemas though to be able to tell the more differences. like checkout schema for e-commerce for examples or forms when we need to do many complex stuffs with conditionals, combinations with some schemas, examples working with some other UI libraries, etc. With cases like real world problems, it would be easier to understand in the experiences I think when it is being used with others stuffs in the ecosystem. |
Beta Was this translation helpful? Give feedback.
-
I'd be thrilled to see it possible to have precision control over the input type while still allowing further validation. I am again up at 1 a.m., fighting with form validation where I need input-only null, and stopped by to see if there have been any changes on this front before I pull my utils from an earlier project. Overall, this looks like a positive direction. The usage seems straightforward. |
Beta Was this translation helpful? Give feedback.
-
Looking from the reusable schema developer perspective, I'm also +1 for extracting Currently, extending schemas means repetitive export function integerNumber(
arg1?: ErrorMessage | Pipe<number>,
arg2?: Pipe<number>,
) {
const [error, pipe] = defaultArgs(arg1, arg2)
return number(error, mergePipes([integer(error)], pipe))
} From what I understand, in this case it will become as simple as: const integerNumber = (error?: ErrorMessage) => pipe(number(error), integer(error)) which is a huge win. |
Beta Was this translation helpful? Give feedback.
-
Feel free to take a look at the draft PR for the |
Beta Was this translation helpful? Give feedback.
-
How would |
Beta Was this translation helpful? Give feedback.
-
Hi folks, I am Fabian, the creator and maintainer of Valibot. Today I am writing this message to you to discuss the further development of Valibot's API. With currently more than 80,000 weekly downloads, the library is growing into a serious open source project used by thousands of JavaScript and TypeScript developers around the world.
In the last few days I received two API proposals from @Demivan and @xcfox. Both of them addressed similar pain points of Valibot's current API design. As our v1 release is getting closer and closer, it is important to me to involve the community in such a big decision.
Current pain points
Valibot's current mental model is divided into schemas, methods, validations, and transformations. Schemas validate data types like strings, numbers and objects. Methods are small utilities that help you use or modify a schema. Validations and transformations are used in the
pipe
argument of a schema. They can make changes to the input and check other details, such as the formatting of a string.A drawback of this API design is that the current pipeline implementation is not modular. This increases the initial bundle size of simple schemas like
string
by more than 200 bytes. This is almost 30% of the total bundle size.Another pain point is that the current pipeline implementation does not allow you to transform the data type. This forced me to add a
transform
method, resulting in two places where transformations can happen.Speaking of methods, it can quickly become confusing if you need to apply multiple methods to the same schema, as these functions must always be nested.
The last pain point that comes to mind is that the current API design gives you less control over the input and output type of a schema. When working with form libraries, it can be useful to have an input type of
string | null
for the initial values, but an output type of juststring
for a required field.The
pipe
functionAfter several design iterations, @Demivan came up with the idea of a
pipe
function. Similar to the currentpipe
argument, it can be used for validations and transformations. The first argument is always a schema, followed by various actions or schemas.The big difference is that the
pipe
function also allows you to transform the data type. This would allow us to move methods liketransform
andbrand
into the pipeline. This prevents nesting of functions for these methods and simplifies the mental model.With the
pipe
function, the mental model is reduced to schemas, methods, and actions. Actions are always used within thepipe
function to further validate and transform the input and type of a schema.The advantages
The
pipe
function makes Valibot even more modular, resulting in a smaller bundle size for very simple schemas without a pipeline. For very complex schemas it reduces function nesting when usingtransform
andbrand
.It also gives you more control over the input and output type, and simplifies the mental model by eliminating the confusion between the
transform
method and the pipeline transformations of the current API.Besides that, the
pipe
function would also allow us to easily add a metadata feature to Valibot. This could be interesting when working with databases to define SQL properties likePRIMARY KEY
.The disadvantages
The main disadvantage of the
pipe
function is that it requires a bit more code to write for medium sized schemas, and for more complex schemas you may end up nesting multiple pipelines.Let's discuss
Your opinion matters to me. I encourage everyone to share their thoughts. Even quick feedback like "I like this ... and I don't like that ..." is welcome and will help to shape Valibot's future API design. Please discuss with me here on GitHub or share your thoughts on Twitter.
Beta Was this translation helpful? Give feedback.
All reactions