ianstormtaylor / superstruct

A simple and composable way to validate data in JavaScript (and TypeScript).
https://docs.superstructjs.org
MIT License
6.96k stars 223 forks source link

custom error messages #568

Open jota opened 3 years ago

jota commented 3 years ago

Hey there, great work with superstruct. I was especially happy to see that it does not bloat the browser bundle like other validation libs do.

One thing is holding me back to switch to superstruct though,

I could not find an easy way to provide a custom error message.

I was looking for something as easy as pattern(string(), /^\d\d\d\d-\d\d-\d\d$/, 'custom error message, the string should be in date format YYYY-MM-DD')

or message(pattern(...), 'custom error message, the string should be in date format YYYY-MM-DD')

The default error message provides the regex used which is not something I would like to bother a user with and there are some other use cases like localization.

Is there a way to do that already?

ianstormtaylor commented 3 years ago

Hey @jota can you explain your use case fully? Eg. what the specific struct is used to validate, where these error messages are going, etc. (with code samples)

jota commented 3 years ago

Hi Ian,

sure. Basically I would like to override the generated error message.

Let's say I have an object with a "date" field where I validate the format YYYY-MM-DD e.g. 2020-12-01. Validation: date: pattern(string(), /^\d\d\d\d-\d\d-\d\d$/), Input: 2020-12-XX Error message: 'At path: date -- Expected a string matching /^\\d\\d\\d\\d-\\d\\d-\\d\\d$/ but received \"2020-12-XX\"'

I would like to change the error message to something else like "date should match format YYYY-MM-DD" because i would like to directly show those messages in a user interface.

I have created a jsfiddle here

I'm currently using yup, where I can use the following: date: yup.string().required().matches(/^\d\d\d\d-\d\d-\d\d$/, 'date must have format YYYY-MM-DD'),

Does that make sense to you?

ianstormtaylor commented 3 years ago

My general recommendation is that I wouldn't necessarily show these error messages directly to a user, because Superstruct provides no guarantee that they are user-facing. Depending on the structure of your validation they could be very confusing.

I don't think I want to add a message argument to every struct because it gets tedious, but also because it makes it hard to compose them if the messages themselves assume things about where the structs are used.

That said, I'd be open to a message(..., string) utility function if you were down to pull request one.

The other solution for you is to define a custom type of your own, especially if you are planning to re-use those date strings in other places in your app, for example:

import { define } from 'superstruct'

const datestring = () => define('datestring', value => ... || 'My custom message.')
jota commented 3 years ago

Your second suggestion pointed me in the right direction.

Now i'm using:


const datestring = define('datestring', (value, context) => {
    if (/^\d\d\d\d-\d\d-\d\d$/.test(String(value))) {
        return [];
    } else {
        return {
                        path: [],
            message: `${context.path} must have format YYYY-MM-DD`,
        };
    }
});

and the error message is "date must have format YYYY-MM-DD".

That works for me, thank you for taking the time!

richarddavenport commented 3 years ago

My general recommendation is that I wouldn't necessarily show these error messages directly to a user, because Superstruct provides no guarantee that they are user-facing. Depending on the structure of your validation they could be very confusing.

I don't think I want to add a message argument to every struct because it gets tedious, but also because it makes it hard to compose them if the messages themselves assume things about where the structs are used.

That said, I'd be open to a message(..., string) utility function if you were down to pull request one.

The other solution for you is to define a custom type of your own, especially if you are planning to re-use those date strings in other places in your app, for example:

import { define } from 'superstruct'

const datestring = () => define('datestring', value => ... || 'My custom message.')

@ianstormtaylor Just curious... are you saying that we could create a utility function (message()) that would take in an error struct/something else(?) and it would spit out user facing error messages?

This library is by far the best, the only thing I'm needing is user facing error messages.

EvgenBabenko commented 3 years ago

@richarddavenport totally agree with u, we realy need this feature with TS create custom structs looks like i just override all library with this tons of generics and types

ianstormtaylor commented 3 years ago

Hey @richarddavenport can you give me an idea for what your use case is for user-facing error messages? There are a few different ways I might recommend doing it.

richarddavenport commented 3 years ago

@ianstormtaylor thanks for being open. Let me through together some notes/code.

EvgenBabenko commented 3 years ago

@ianstormtaylor hi, i've created a new PR with custom errors and i think in this way all of us wanted see custom errors in others functions

richarddavenport commented 3 years ago

@EvgenBabenko I think the goal of this issue is to be able to see User facing error messages. For example if a form input is required (like name) then you would validate with superstruct and then if you need to show the user a message you could do it with a custom message.

Example:

const User = object({
  name: string(),
});
const data = {
  name: null,
};
assert(data, User);
// produces:
{
  "message": "At path: name -- Expected a string, but received: null",
  "value": null,
  "type": "string",
  "path": [
    "name"
  ],
  "branch": [
    {
      "name": null
    },
    null
  ]
}

Given the previous I would not show the user a toast notification displaying "At path: name -- Expected a string, but received: null". Superstruct is very useful for the developer but not the user... I would rather show the user "Name is required"

EvgenBabenko commented 3 years ago

@richarddavenport so, we have different approaches for show messages to users, cuz we're using react-hook-form and we're trying connect react-hook-form with superstruct instead Yup I've prepared codesanbox example. I've created required struct, but it doesn't matter, look on second parameter "message", without it we show defaulted message, i think about this feature by default told @jota, because as i said before, with typescript creating structs with custom errors, lool like "I just override all library in my app"

richarddavenport commented 3 years ago

@EvgenBabenko I agree with what you're saying. Nothing I've said goes against what you've said. We're actually discussing achieving the same result. It's how we get there is in question.

PR #611 (which has some typos FYI) is only good for creating required structs. What I'd like to see is the ability to return a particular message given a certain error, so not just required.

Here's another example:

const User = object({
  name: size(string(), 1, 15),
});

Required

assert({ name: null }, User);
// produces message: "At path: name -- Expected a string, but received: null"

Too Short

assert({ name: '' }, User);
// "At path: name -- Expected a string with a length between `1` and `10` but received one with a length of `0`"

Too Long

assert({ name: 'Very Long Name That is Too Long' }, User);
//"At path: name -- Expected a string with a length between `1` and `10` but received one with a length of `31`",

What I am trying achieve is the ability to show different messages based on the output of the validation.

Given your currently solution, how would I show multiple messages for the following? I need one message for missing, one for too short, and one for too long.

const User = object({
  name: size(string(), 1, 15),
});
EvgenBabenko commented 3 years ago

@richarddavenport thanks for find some typos, i haven't noticed, now i see

I think, your expected result is impossible, cuz in your result need customize each border (don't forget about internalization) Maybe solution will be smth like this: const User = object({ name: size(required(string("Name should be string"), "Name is required"), 1, 15, "Name should be between 1 and 15 letters"), }); (i don't know how to more customize this code, like your, mine looks terrible)

or in your case maybe need write more custom structs: const User = object({ name: max(min(required(string("Name should be string"), "Name is required"), 1, "Name is too short"), 15, "Name is too long"), }); where required/min/max is custom structs

and my PR just showing, that in every function we're expecting message argument @ianstormtaylor

richarddavenport commented 3 years ago

@EvgenBabenko Yes, I was hoping you'd come to the same conclusion I would. With your solution it is not possible. That's why I want to propose is different than what you are proposing currently.

I need to give it more thought, but basically I don't think we should couple the user facing messages with the superstruct messages.

This is what @ianstormtaylor is suggesting in comments previously.

I don't think I want to add a message argument to every struct because it gets tedious, but also because it makes it hard to compose them if the messages themselves assume things about where the structs are used.

What we're trying to get to is a composable approach.

kamijin-fanta commented 3 years ago

I also use react-hook-form. I want to output an error message using a different format for each Input. For example, "size" outputs the same error string as an array and a string, but it needs to be changed depending on the context. I also want to use the i18n library.

I think that it can be solved by outputting all the error information. For example, there are two types of errors that size(string(), 1, 10) outputs. Perhaps this is type-safely expressed as follows.

// This code doesn't work
interface StringValidationError {
  class: "invalidType";
  expect: "string";
  actually: string;
}
interface SizeValidationError {
  class: "invalidSize";
  min: number;
  max: number;
  actually: number;
}

const stringStruct: Struct<string, null, StringValidationError> = string();
const sizeStruct: Struct<string, null, SizeValidationError | StringValidationError> = size(string(), 1, 10);

The user can format the error message according to the nature of the application and i18n requirements. I think this can be implemented by extending the current StructError.

kamijin-fanta commented 3 years ago

I'm working on a POC here. It is considered to break compatibility. https://github.com/ianstormtaylor/superstruct/compare/main...kamijin-fanta:typed-error?expand=1

@ianstormtaylor Do you have any plans to type the error?

ianstormtaylor commented 3 years ago

Hey @kamijin-fanta, I'm open to adding types to the errors.

The only two issues I see with that approach currently are (a) that it doesn't appear to apply to places where the error is thrown which makes it less useful, and (b) that it eliminates the ease of return true/false or return 'message' which makes it harder to define custom structs quickly.

Are there ways to solve those issues while still typing the errors?

kamijin-fanta commented 3 years ago

Thank you!

I was thinking the same. I think it can be solved by adding the expected type name to the define and refine functions and the logic that accepts boolean, string and undefined. It may be possible to create new functions like defineType(name: string, expect: string, validator: (value, ctx) => boolean | undefined): Struct<T, S, TypeErrorDetail>.

I have no good idea about Throw. Struct that may throw an error may be solved by using for example TypeErrorDetail | ThrowErrorDetail.

interface ThrowErrorDetail extends ErrorDetail {
   class:'throw';
   error: any;
}
orhels commented 3 years ago

Utility-function based approach that worked for us:

Function:

const message = <T>(struct: Struct<T, any>, message: string): Struct<T, any> =>
    define('message', (value) => (is(value, struct) ? true : message));

Usage:

fieldA: message(string(), 'required'),

When validating with react-hook-form resolver we get this validation error:

{message: "required", type: "message"}

We then pass the message to our i18n function to give validation messages to the user. I'm guessing that this function is not a general solution, but it would be great to have something like this in the lib.

tyteen4a03 commented 2 years ago

Any chance message can be a part of standard API?

EddyVinck commented 1 year ago

I don't think this issue should be closed. What do you think @ianstormtaylor ?

I tried @orhels solution as it seems like a good approach.

CodeSandbox: https://codesandbox.io/s/react-superstruct-hook-form-custom-errors-translated-83jx90?file=/src/contact-form.tsx (CodeSandbox is being a bit weird sometimes with the dependencies. Might need to refresh a couple times for them to work.)

It includes:

Hopefully the example is useful to someone.

ianstormtaylor commented 1 year ago

I'm open to the idea of a message utility. Although I'd like to see it implemented where it doesn't end up wrapping and creating a new error, but instead just augments the existing error by changing it's message. If someone wants to PR that I'd be open to it!

And I also still would love the approach @kamijin-fanta mentioned and started implementing in https://github.com/ianstormtaylor/superstruct/pull/634.

Azarattum commented 1 year ago

It would be nice to be able to define an optional general assertion message that would augment the superstuct's validation message. Like:

import { assert } from "superstruct";

assert(user, UserInfo, "Failed to parse user!");
// or
const valid = create(user, UserInfo, "Failed to parse user!");

So that in logs, when you see a random validation error, you can tell that it was user related. I'm currently using a wrapper around superstruct's functions which isn't very nice. I think this in the kind of feature that should be handled by the library itself.

@ianstormtaylor, let me know if you'd be open to a PR for this.

My Workaround

import {
  Struct,
  assert as test,
  create as parse,
  StructError,
} from "superstruct";

export function assert<T, S>(
  value: unknown,
  struct: Struct<T, S>,
  message = ""
): asserts value is T {
  try {
    test(value, struct);
  } catch (error) {
    if (error instanceof StructError && message) {
      error.message = message + "\n" + error.message;
    }
    throw error;
  }
}

export function create<T, S>(
  value: unknown,
  struct: Struct<T, S>,
  message = ""
) {
  try {
    return parse(value, struct);
  } catch (error) {
    if (error instanceof StructError && message) {
      error.message = message + "\n" + error.message;
    }
    throw error;
  }
}
ianstormtaylor commented 1 year ago

@Azarattum that's a nice idea. I'd be open to a PR.

I think if the message is provided, it should override the error message completely, instead of concatenating them. But not sure if that would make the feature useless to you, in which case maybe it's better left to userland.

Azarattum commented 1 year ago

@ianstormtaylor I think we can put the original message into Error.cause which is a standard web API and seems like a perfect place for it. So, developers can access it if needed.

Now the question is, which APIs should we augment? assert and create are the obvious ones. But, what do you think about validate? We already have an optional third argument there. So, shall we do validate(value, User, { coerce: true }, message), validate(value, User, { coerce: true, message: message }) or just leave it alone and change nothing?

ianstormtaylor commented 1 year ago

@Azarattum wow learned something new about Error.cause, that's a great solution. I think doing it as validate(value, struct, { coerce, message }) sounds like the best way to go, so that everything further up the chain gets it the same way.

Azarattum commented 1 year ago

Done in https://github.com/ianstormtaylor/superstruct/pull/1141. @ianstormtaylor, could you take a look?

mi-na-bot commented 1 year ago

I am seriously considering porting some react-hook-form forms to this library from zod, but not being able to clean up the error messages into something for a human in the schema is a significant obstacle. It looks like we can use custom assertations with the PR above, but that would mean never using the built-in functions with react-hook-form, which is not perfect.

They provide a resolver that works quite well, besides the for-robots error messages: https://react-hook-form.com/get-started#SchemaValidation

shellscape commented 1 year ago

@ianstormtaylor thoughts on the last comment above?

christopher-caldwell commented 8 months ago

@minervabot and anyone else interested, I had a crude go at making a resolver for react-hook-form with custom error messages. Here's my first pass. Can certainly be improved.

It's basically what the resolver library already provides, with the addition of an error message display map.

import { FieldErrors, FieldValues, Resolver } from 'react-hook-form'
import * as s from 'superstruct'

const safelyValidateSchema = <TSchema>(input: unknown, schema: s.Struct<TSchema>) => {
  const result: ValidationResult<TSchema> = { data: undefined, error: null }
  try {
    result.data = s.create(input, schema)
  } catch (e) {
    if (e instanceof s.StructError) {
      result.error = e
    }
    result.data = input as TSchema
  }
  return result
}

export const resolverFactory =
  <TSchema extends FieldValues>(schema: s.Struct<TSchema>, options: ResolverOptions<TSchema>): Resolver<TSchema> =>
  (input) => {
    const { data, error } = safelyValidateSchema(input, schema)
    if (!error && data) {
      return {
        values: data,
        errors: {},
      }
    }
    if (!error && !data) {
      console.error('No error and no data, this should not be happening!')
      return {
        values: {},
        errors: {},
      }
    }
    if (error) {
      const errors: Record<string, s.Failure> = {}
      for (const failure of error.failures()) {
        const customErrorMessage = options.errorMap[failure.key as keyof ListingsParams]
        if (customErrorMessage) errors[failure.key] = { ...failure, message: customErrorMessage }
        else errors[failure.key] = failure
      }
      return {
        values: {},
        errors: errors as unknown as FieldErrors<TSchema>,
      }
    }
    throw new Error('Bad news bears')
  }

type ValidationResult<TSchema> = {
  data: TSchema | undefined;
  error: s.StructError | null;
}

type ResolverOptions<TSchema> = {
  errorMap: Partial<Record<keyof TSchema, string>>
}

Usage

const resolver = resolverFactory(Schema, { errorMap: { maxDaysOnSite: 'Oopsie' } })
useForm({ resolver })

Result Screenshot 2023-12-23 at 9 03 20 PM

wcastand commented 3 months ago

Anyone found a solution for react-hook-form on this one?

i'd like to custom the error msg based on locale but also the error so the same key/input can have different error msg depending on the error. not sure how to do that with that i've seen above. the current solution i see offered is to make custom validation type for every input and msg we have :/

on an basic input let's say i want to be able to say something like:

not sure i found in the doc how to do that :/

mi-na-bot commented 3 months ago

@wcastand The approach I experimented with was to copy the superstruct resolver code in @hookform/resolvers/superstruct to my own codebase and extend that with personalized error messages based on the error key as demonstrated in https://github.com/ianstormtaylor/superstruct/issues/568#issuecomment-1868421657

It seems daunting to need to maintain/replace the provided resolver, but actually the code is VERY short and never changes. https://github.com/react-hook-form/resolvers/blob/master/superstruct/src/superstruct.ts

This is actually a pretty clean solution and there is a certain charm in pushing the presentation display code into the display layer, but I did end up sticking with Zod.