gcanti / io-ts

Runtime type system for IO decoding/encoding
https://gcanti.github.io/io-ts/
MIT License
6.68k stars 331 forks source link

[Question] Can I validate one property value based on the values of other properties? #669

Closed maddiehosseini closed 1 year ago

maddiehosseini commented 1 year ago

🚀 Feature request

Current Behavior

I have a schema defined like this:

export const Contact = t.type({
  firstName: t.union([
    withMessage(
      tt.NonEmptyString,
      () => ContactValidationErrorType.DataRequired
    ),
    t.undefined,
  ]),
 lastName: t.union([
    withMessage(
      tt.NonEmptyString,
      () => ContactValidationErrorType.DataRequired
    ),
    t.undefined,
  ]),
 phoneNumber: t.union([
    withMessage(
      validatePhoneNumber,
      () => ContactValidationErrorType.DataRequired
    ),
    t.undefined,
  ]),
});

Desired Behavior

I would like to apply this rule: "at least one of the name fields should have value".

Who does this impact? Who is this for?

TypeScript users

Describe alternatives you've considered

I can do the name validation in a separate function and have a 2-steps validation, but I prefer not to do it. Because other team members may later forget to use it and miss part of the validation logic.

Your environment

Software Version(s)
io-ts 2.2.13
TypeScript 4.7.4
mlegenhausen commented 1 year ago

You can define via a union that only firstName or lastName or both are required.

export const Contact = t.intersection([
  t.union([
    t.type({ firstName: tt.NonEmptyString }),
    t.type({ lastName: tt.NonEmptyString }),
    t.type({
      firstName: tt.NonEmptyString,
      lastName: tt.NonEmptyString
    })
  ]),
  t.partial({
    phoneNumber: validatePhoneNumber
  })
]);
maddiehosseini commented 1 year ago

Thank you, using the intersection and partial solved the problem 👍 I modified your solution a bit so it's easier to understand for other devs who read the code later:

export const Contact = t.intersection([
  t.union([
    firstNameShouldHaveValue,
    lastNameShouldHaveValue,
    nickNameShouldHaveValue
  ]),
  t.partial({
    phoneNumber: validatePhoneNumber
  }),
]);

const firstNameShouldHaveValue =  t.type({
      firstName: withMessage(
        tt.NonEmptyString,
        () => ContactValidationErrorType.AllNameFieldsAreEmpty
      ),
      lastName: t.union([
        withMessage(
          t.string,
          () => ContactValidationErrorType.InvalidCompanyName
        ),
        t.undefined,
      ]),
      nickName: t.union([
        withMessage(
          t.string,
          () => ContactValidationErrorType.InvalidNickName
        ),
        t.undefined,
      ]),
    });

const lastNameShouldHaveValue = t.type({
      firstName: t.union([
        withMessage(t.string, () => ContactValidationErrorType.InvalidFirstName),
        t.undefined,
      ]),
      lastName: withMessage(
        tt.NonEmptyString,
        () => ContactValidationErrorType.AllNameFieldsAreEmpty
      ),
      nickName: t.union([
        withMessage(
          t.string,
          () => ContactValidationErrorType.InvalidNickName
        ),
        t.undefined,
      ]),
    });

const nickNameShouldHaveValue = t.type({
      firstName: t.union([
        withMessage(t.string, () => ContactValidationErrorType.InvalidFirstName),
        t.undefined,
      ]),
      lastName: t.union([
        withMessage(
          t.string,
          () => ContactValidationErrorType.InvalidLastName
        ),
        t.undefined,
      ]),
      nickName: withMessage(
        tt.NonEmptyString,
        () => ContactValidationErrorType.AllNameFieldsAreEmpty
      ),
    });