DEV Community

kevelopment
kevelopment

Posted on

Unified Validation & Typings in Web-Apps using yup

The root Issue

In most cases defining and implementing Types is a repetitive (and nasty) Task for Full-Stack developers. This usually includes implementing kind of the same stuff in multiple locations:

  • Entity Types in the DB-Layer
  • Validation Schemas for Request Data
  • Response Types for the API-Layer (GraphQL or REST)
  • (Prop-) Types and Validation for Forms in the Frontend

How to tackle this issue?

One way I figured when using NestJS in combination with React is to use yup (in combination with other third party libraries though).
In React we can utilize Formik which natively supports validation via yup schemas and in the NestJS Backend we can use nestjs-yup which is quite handy and straight forward to use as well. Btw: This works for both, GraphQL- as well as Rest-APIs built with Nest. 👌

Step 1) Shared library: Schema implementation & Type definition

So let’s start off with a central place (a shared library for instance) where we’ll define the schemas as well as the actual types.

IPerson.ts

export const PersonSchema = yup.object({
  firstName: yup
    .string()
    .min(2, "Too Short!")
    .max(50, "Too Long!")
    .required("Required"),
  lastName: yup
    .string()
    .min(2, "Too Short!")
    .max(50, "Too Long!")
    .required("Required"),
  email: yup.string().email("Invalid email").required("Required"),
});

export const UpdatePersonSchema = BaseSchema.concat(
  yup.object({
    firstName: yup.string().notRequired(),
    lastName: yup.string().notRequired(),
    email: yup.string().email("Invalid email").notRequired(),
  })
);

export interface IPerson {
  firstName: string;
  lastName: string;
  email: string;
}

export interface IUpdatePerson extends IUpdateBase, Partial<IPerson> {}
Enter fullscreen mode Exit fullscreen mode

Another way to let yup generate the types automatically is the following:

type PersonType = yup.InferType<typeof PersonSchema>;
Enter fullscreen mode Exit fullscreen mode

In the long term I found this less useful since there’s a lot of internal Typings that prevent straight forward error messages. Furthermore optionals ? won’t work at all when implementing the interfaces in e.g. entities.

Step 2) Backend: Entity / Response Type definition

Here we’ll make use of the library nestjs-yup which will provide the necessary Decorators for easy usage.

First step here is to implement the Entity (the ORM Framework used in this example is typeorm). The important part here is that we can use the interfaces defined in the shared type so our Entity is forced to implement the fields defined in IPerson (hence requiring adjustments once something changed in the interface declaration).

person.entity.ts

@Entity()
@ObjectType()
export class Person extends Base implements IPerson {
  @Field()
  @Column("text")
  firstName: string;

  @Field()
  @Column("text")
  lastName: string;

  @Field()
  @Column("text")
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

When creating a new User we’ll use the validation logic implemented in the UserSchema (requiring a password as well as a username). The Decorator @UseSchema(Schema) will register the Schema internally to be used by the YupValidationPipe later on automatically.

create-person.input.ts

@InputType()
@UseSchema(PersonSchema)
export class CreatePersonInput implements IPerson {
  @Field()
  firstName: string;

  @Field()
  lastName: string;

  @Field()
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

For the Person-Update-Type we’ll make use of Partial Types which will basically mark all attributes as optional (which we did in the Schema as well). So we have to declare the Fields as nullable and register the UseSchema for this Input-Type.

update-person.input.ts

@InputType()
export class UpdatePersonInput
  extends PartialType(CreatePersonInput)
  implements IUpdatePerson
{
  @Field(() => ID)
  id: string;
}
Enter fullscreen mode Exit fullscreen mode

Last but not least we will register the YupValidationPipe globally so each and every Endpoints using any of the Classes decorated with @UseSchema(Entity) will be validated automatically using the schema that was given to the decorator.

main.ts

// … 
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(new YupValidationPipe());

Enter fullscreen mode Exit fullscreen mode

Another option would be to just decorate each and every desired Endpoint with

@UsePipes(new YupValidationPipe())
Enter fullscreen mode Exit fullscreen mode

to validate the request data.

Frontend: Form Types / Props definition

In our React App we’ll create a plain and simple Form-Component to validate the data entered to supposedly create a new Person (without any actual update or creation calls to the backend).

person.tsx

const initialPerson = {
  firstName: "",
  lastName: "",
  email: "",
} as IPerson;

export const Person = () => (
  <div>
    <h1>Person</h1>
    <Formik
      initialValues={initialPerson}
      validationSchema={PersonSchema}
      onSubmit={(values) => {
        console.log("submitting: ", { values });
      }}
    >
      {({ errors, touched }) => (
        <Form>
          <div className={`${styles.flex} ${styles.column}`}>
            <Field name="firstName" placeholder="FirstName" />
            {errors.firstName && touched.firstName ? (
              <div>{errors.firstName}</div>
            ) : null}
            <Field name="lastName" placeholder="LastName" />
            {errors.lastName && touched.lastName ? (
              <div>{errors.lastName}</div>
            ) : null}
            <Field name="email" placeholder="E-Mail" />
            {errors.email && touched.email ? <div>{errors.email}</div> : null}
            <button type="submit">Submit</button>
          </div>
        </Form>
      )}
    </Formik>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

And that's it 🙌 Well at least for now, handling the creation of a new Person and updating an existing Person will follow (probably in my next Post). 😊

Conclusion

To be fair: it's not the "one-size-fits-all" kind of solution since validation for the DB-Layer (via @Column({nullable: true})) still has to be added manually. BUT it makes dealing with the same types in the Frontend as well as the Backend much easier because all of them are based on the same shared interface. So if something changes there ts-compiler will complain when e.g. running the tests and you'll know which places will have to be adjusted accordingly.

Another practice or habit I found is that you can use the convention to set e.g. the Field as well as the Column to nullable: true once the attribute of the implemented interface is optional ?.

You can find the code here on Github. 🥳

Discussion (0)