TypeScript is a fantastic language. It gives lift to the JS language by allowing pseduo-typing making it easier to understand what's coming in and out. Let's look at a simple TS example with the idea of retrieving a user from the database.
interface IUser {
firstName: string;
middleName: string;
lastName: string;
birthday: Date;
}
class UserRepository {
private readonly db: any; // this is some way to access the DB, not important
constructor(db: any) {
this.db = db;
}
async getUsers(): IUser[] {
const users: IUser[] = await this.db<IUser>.getUsers();
return users;
}
}
Looking at this, we have a simple user object and return all of them from the database. It looks like there's only 4 properties and all are required. There's a glaring issue here, though. What if the database type doesn't match the TypeScript type? Let's say for example in the database, middleName
isn't required, and you need to print out the whole name of the user. Not everyone has a middle name, so it makes sense that the db doesn't require it. With the wrong type, when you go in and need to implement this function, you'd probably do something like this.
const getFullName = (user: IUser): string =>
`${user.firstName} ${user.middleName} ${user.lastName}`;
But this won't work. What if my name is just John Doe? Then my full name would look like this when printed John null Doe
. While null
is a cool middle name, that's not my name! The problem is that in TypeScript the types don't exist at runtime. Let's look at the output from the TS compiler for our simple function.
interface IUser {
firstName: string;
middleName: string;
lastName: string;
birthday: Date;
}
const getFullName = (user: IUser): string =>
`${user.firstName} ${user.middleName} ${user.lastName}`
gets compiled down into
"use strict";
const getFullName = (user) =>
`${user.firstName} ${user.middleName} ${user.lastName}`;
which means that our types aren't real and only help us if they're right.
What we can do is create objects that are required to have the correct type at runtime. Here I'm going to use the library Joi, but there are others that accomplish the same goal. What Joi will do is validate an object that comes in according to the rules we set.
Let's create an object that will be safe for us at runtime by first creating a Joi schema that will mimic what our IUser
interface looks like.
import * as Joi from 'joi';
const userSchema = Joi.object({
firstName: Joi.string().required(),
middleName: Joi.string().allow(null),
lastName: Joi.string().required(),
birthday: Joi.date().required()
});
It's a simple schema, but let's go through it at a high level. Basically all we have 3 required properties (firstName, lastName, birthday) and one that isn't required. The allow(null)
is so that Joi doesn't complain when the property exists but is null. We see that firstName, middleName, lastName, and birthday are all strings while birthday is a date. There's plenty of built in functionality there that I won't go over since it's extraordinarily powerful, but that's the gist of this simple schema. If we passed 1
into firstName
, it would fail the schema validation.
Let's first write the validation method.
import * as Joi from 'joi';
const validateObject = (schema: Joi.schema, data: any): any => {
const res = schema.validate(data, { stripUnknown: true });
if(res.error) {
throw new Error('failed validation');
}
return res.value;
}
Nothing fancy. If the validator from Joi errors, we throw our own error. The res.value
is the object that has been validated. The { stripUnknown: true }
we passed into the validate method will remove any unknown keys so that we only get what we validated back from the res.value
.
Next up lets create our actual User
class that has the validated properties assigned.
class User {
public readonly firstName: string;
public readonly middleName: string | null;
public readonly latsName: string;
public readonly birthDay: Date;
private readonly schema = Joi.object({
firstName: Joi.string().required(),
middleName: Joi.string().allow(null),
lastName: Joi.string().required(),
birthday: Joi.date().required()
});
constructor(objToValidate: any) {
const res = validateObject(obj, this.schema);
this.firstName = res.firstName;
this.middleName = res.middleName;
this.lastName = res.lastName;
this.birthDate = res.date;
}
}
As you can see, we have an object with our 4 properties that are readonly. That way we can stop people from manipulating them without going through the validation. A potential idea could be to add setters for them that would go through the validation additionally, but I'll leave that to you to expand upon :) After we validate the object in the constructor, we assign the properties from the validation to our properties. As said above, since we stripped the properties and our keys in our Joi schema match we can use the result from the validation to assign the properties.
Now here's our initial example with our new User
type.
class UserRepository {
private readonly db: any; // this is some way to access the DB, not important
constructor(db: any) {
this.db = db;
}
async getUsers(): User[] {
const users: any[] = await this.db.getUsers();
return users.map(u => new User(u));
}
}
There's a little problem here, in that we still have our public properties on our object that can be wrong. If we were to omit the | null
from our middle name, we'd be in the same predicament as earlier. What this does do for us is validate the object is required to pass the schema we have, meaning that we have the object shape we expect, and we have. A typical case would be that in our database schema we initially required middleName
, but change our DB schema to allow it to be null. This could cause hidden bugs because our type isn't there at runtime, but our validation is. Additionally, we can validate way more than just simple strings and dates. We could do things like validate that our ids are valid uuids or any other number of rules, allowing us to avoid potential bugs.
My favorite alternative to Joi would have to be class-validator.
Top comments (0)