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> {}
Another way to let yup
generate the types automatically is the following:
type PersonType = yup.InferType<typeof PersonSchema>;
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;
}
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;
}
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;
}
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());
…
Another option would be to just decorate each and every desired Endpoint with
@UsePipes(new YupValidationPipe())
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>
);
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. 🥳
Top comments (3)
My company followed a similar approach and I have identified several drawbacks which are not easy to revert...
auto-swagger
API documentation. Now you have to manage everything manually. Developers just give up on maintaining the swagger schemas or even when they try, a bunch of inconsistencies exist.AppError
or aYupAppError
.frontend
andbackend
logic.TL;DR: To which their own, but in my opinion class validator is amazing and we should not fight the framework. Not to mention that JavaScript is so volatile that tomorrow Yup can become obsolete from day to night and we are left with a subpar experience on the backend just because we reinvented the wheel.
I agree 💯💯
There are type issues with
@UseSchema(PersonSchema)
with error