About two years ago, when I completed my first full-stack development course, I wanted to create my own web app. It was supposed to be a simple movie searching app.
I managed to gather some movie data from one of the MongoDB sample databases called mflix. I prepared my SQL database with those sample movies and was working on the POST endpoint responsible for creating new movies.
The obvious next task was validation, and the movie schema was fairly complex including multiple nested objects and arrays. I learned a little bit about Joi, a validation library, from that course, but I didn't know enough to write a validation schema for the movie object on my own.
So, I went to the Joi documentation site and started reading it from top to bottom. As a beginner who wasn't used to reading documentation back then, I found it super boring and confusing. I told myself: dammit, I'll create my own validation library!
If you've never worked with a validation library before then let me give you a brief introduction. Suppose, you have an object with the following schema:
interface User {
name: string;
email: string;
birth_year: number;
}
Then with Joi, you'll write a validation schema like the following:
const Joi = require("joi");
const schema = Joi.object({
name: Joi.string().alphanum().min(3).max(30).required(),
email: Joi.string().email({
minDomainSegments: 2,
tlds: { allow: ["com", "net"] },
}),
birth_year: Joi.number().integer().min(1900).max(2013),
});
schema.validate({ username: "abc", birth_year: 1994 });
// -> { value: { username: 'abc', birth_year: 1994 } }
schema.validate({});
// -> { value: {}, error: '"username" is required' }
It's much more clean and scalable then using raw if/else
statements. With that out of the way, let's go back to building my validation library from scratch.
Let's look closely at the name
property from the User
object schema.
const nameSchema = Joi.string().alphanum().min(3).max(30).required();
I was a bit worried at first, because I didn't know how to implement this method chaining. But it turned out to be very simple; we just need to return this
from every method.
class StringSchemaRegister {
min(val) {
return this;
}
max(val) {
return this;
}
}
function string() {
return new StringSchemaRegister();
}
const schema = string().min(2).max(3);
Now we've got to store these min
and max
values in an array with proper order. Let's modify the StringSchemaRegister
class.
const getter_key = Symbol();
class StringSchemaRegister {
#register = [];
min(value) {
const schema = { name: "min", value };
this.#assertInteger(schema);
this.#register.push(schema);
return this;
}
max(value) {
const schema = { name: "max", value };
this.#assertInteger(schema);
this.#register.push(schema);
return this;
}
/*
I'm using this secret symbol key to access the register array because I want
to keep it hidden from the end user.
*/
[getter_key]() {
// cloning the #register array. @TODO use lodash deepClone utility
return this.#register.map((obj) => ({ ...obj }));
}
#assertInteger({ value, name }) {
if (!Number.isInteger(value))
throw new Error(`The ${name} value must be an integer.`);
}
}
function string() {
return new StringSchemaRegister();
}
const schema = string().min(2).max(3);
console.log(schema[getter_key]());
// [
// { name: "min", value: 2 },
// { name: "max", value: 3 },
// ]
Do you see a problem here? I've got to manually create every method and validate their arguments. Oh man! This args validation became so tiresome that I ended up creating another small validation library called handy-types (what's wrong with me 🤦♂️?!).
Whatever, moving on, I realized that if I can come up with a schema like the following and somehow can programmatically create the StringSchemaRegister
class, it would be great.
// pardon my terrible variable naming
const SchemaOfStringRegisterSchema = {
min: { args: ["integer"] },
max: { args: ["integer"] },
label: {args: ["non_empty_string"]}
alphanum: {}, // no args means it's a boolean flag
};
Ok, with this strategy, I faced another problem.
const schema = string().min(5);
// ^-----> How would I know that `min` is being accessed
// here?
Though now I know how to solve this problem programmatically (without Proxy), at the time I didn’t have a clue. I tried to search for "how to hook JS object property access". After a lot of searching, I landed on an unanswered StackOverflow question.
So I kind of gave up on this project because I’m too lazy to write all the validation methods manually. Until one day, I was watching a LiveOverflow video and saw that guy using something called JavaScript Proxy to spy on an object and log what properties were being accessed. I silently screamed Eureka! and quickly searched on MDN to learn about the topic. It was the exact solution to my problem that I had been searching for weeks.
Allow me to introduce the JavaScript Proxy. A proxy is an object that controls access to another object, called the subject.
To create a Proxy we need two parameters:
- target: the original object which you want to proxy
- handler: an object that defines which operations will be intercepted and how to redefine intercepted operations.
Example:
const target = { value: "abc" };
const handler = {
get(target, property, receiver) {
console.log(target, property, receiver);
return "default value";
},
};
const proxy = new Proxy(target, handler);
console.log(proxy.name);
// Logs:
// { value: 'abc' } name { value: 'abc' }
// default value
console.log(proxy.value);
// Logs:
// { value: 'abc' } value { value: 'abc' }
// default value
Here, the get method in the handler is called a trap method, which intercepts all property access operations and returns whatever value we want. I invite you to read the JS Proxy documentation.
Armed with this powerful knowledge, I went back to my validation project and replaced the StringSchemaRegister
class with the following string function:
const getter_key = Symbol();
const SchemaOfStringRegisterSchema = {
alphanum: { args: [] },
max: { args: ["integer"] },
min: { args: ["integer"] },
label: { args: ["non_empty_string"] },
};
function string() {
const proxy = new Proxy({}, { get });
const register = [];
function get(_, property) {
if (property === getter_key) return register.map((obj) => ({ ...obj }));
else if (!(property in SchemaOfStringRegisterSchema)) return;
const propSchema = SchemaOfStringRegisterSchema[property];
return (...args) => {
// @TODO validate args before pushing to the register
register.push({
name: property,
args: args.slice(0, propSchema.args.length),
});
return proxy;
};
}
return proxy;
}
const schema = string().min(2).max(5).alphanum().label("Name");
console.log(schema[getter_key]);
// [
// { name: 'min', args: [ 2 ] },
// { name: 'max', args: [ 5 ] },
// { name: 'alphanum', args: [] },
// { name: 'label', args: [ 'Name' ] }
// ]
After this, everything was straightforward. I did complete the project and build a crappier version of Joi. Ironically, while building it, I had to read the documentation of multiple validation libraries (Joi, Yup, Zod, etc.) to take inspiration and learn about common validator features—the very thing I was trying to avoid in the first place!
I'm currently working on the second version of my validation library. It'll be similar to Zod, with static type inference but with some very cool and unique features.
Thank you for reading this far. I hope you found this post helpful and informative.
The cover image is taken from Unsplash.
Top comments (0)