fabian-hiller / valibot

The modular and type safe schema library for validating structural data šŸ¤–
https://valibot.dev
MIT License
5.67k stars 170 forks source link

Reduce boilerplate for custom actions #614

Closed IlyaSemenov closed 1 month ago

IlyaSemenov commented 1 month ago

Before the new pipe API, I was creating ad-hoc validators with something like:

export function nonEmpty<TInput extends string | any[]>(error?: ErrorMessage) {
  return minLength<TInput, 1>(1, error)
}

after the update, the least I could come up with was:

// Not exported by valibot, need to redefine this.
type LengthInput = string | unknown[]

export function nonEmpty<TInput extends LengthInput, const TMessage extends
  | ErrorMessage<MinLengthIssue<TInput, 1>>
  | undefined>(message?: TMessage): MinLengthAction<TInput, 1, TMessage>

export function nonEmpty(message = "Non-empty value required.") {
  return minLength(1, message)
}

If I remove the function definition boilerplate and only keep the last 3 lines, it works but it's not typed properly:

image

My question is, can we come up with some kind of approved recipe and/or reduced boilerplate for trivial library extensions such as above? In my perfect world, it should be possible to come up with multitude of similar one-liners, not having to toss unreadable mess of copy/pasted generics.

fabian-hiller commented 1 month ago

Thanks for the feedback. I will check if I want to export these types, but in general it is probably a good idea. nonEmpty is now natively supported out of the box, so you don't have to add it yourself.

In general, there is no difference from before. The only difference is that the API has become more type-safe, which requires more precious typing, resulting in more TypeScript code. But if you don't care, you can write it much the same way as before. Here is an example. You can test it in this playground.

import * as v from 'valibot';

type LengthInput = string | unknown[];

function nonEmpty<TInput extends LengthInput>(
  message: string = 'Non-empty value required.'
): v.MinLengthAction<TInput, 1, string> {
  return v.minLength(1, message);
}

If you prefer precise types, I have rewritten your code to make it a bit simpler. You can test it in this playground.

import * as v from 'valibot';

type LengthInput = string | unknown[];

function nonEmpty<
  TInput extends LengthInput,
  const TMessage extends v.ErrorMessage<v.MinLengthIssue<TInput, 1>>,
>(
  message: TMessage = 'Non-empty value required.' as TMessage
): v.MinLengthAction<TInput, 1, TMessage> {
  return v.minLength(1, message);
}
IlyaSemenov commented 1 month ago

Another use case is:

export function integerNumber<TMessage extends v.ErrorMessage<v.NumberIssue>>(message?: TMessage) {
  return v.pipe(v.number(message), v.integer())
}

is there a better way to type this? Most of the schemas use function overload to distinguish between the message and undefined:

declare function number(): NumberSchema<undefined>;
declare function number<const TMessage extends ErrorMessage<NumberIssue> | undefined>(message: TMessage): NumberSchema<TMessage>;

Is there a way to proxy this contract somehow to the extending one-liners?

fabian-hiller commented 1 month ago

As I understand it, this is not possible without drawbacks. That's why I use overload signatures.

fabian-hiller commented 1 month ago

I added ContentInput, ContentRequirement, LengthInput, SizeInput and ValueInput to the exports.