Let's say we have these interfaces:
interface StringRule { required: boolean; type: StringConstructor; }
interface BoolRule { required: boolean; type: BooleanConstructor; }
interface User { name: string; dateOfBirth: string; picture: { alt: string; src: string; }; isHydrated: boolean; }
And this associated requirement - creating a validation schema:
Given that we have the User interface, we need to create an object which follows these two rules:
This will create a new object, called validator
.
This will be passed into validate
, along with 1 piece of user data to check whether it is valid:
const user = { name: 'Rogan', dateOfBirth: '20/20/2020', picture: { alt: 'A picture of Rogan', src: 'https://content.com', }, isHydrated: false, } const validator = {} // we will define. validate(validator, user); // true
So, now that we've defined the problem - try using this editor to construct the validation object where the comment is.
If you don't fancy it then don't worry.
Note also that this is only for 1 small interface, in real-world applications you will have tens, even hundreds of interfaces coming from various domains - so this problem can exponentially become a pain in the arse if you allow this to become a problem.
You could have approached it in these ways:
Let's work through some possible solutions:
const validator = { name: { required: true, type: String, }, dateOfBirth: { required: true, type: String, }, picture: { alt: { required: true, type: String, }, src: { required: true, type: String, } }, isHydrated: { required: false, type: Boolean, } }; interface User { name: string; dateOfBirth: string; picture: { alt: string; src: string; }; isHydrated: boolean; } const user: User = { name: 'Rogan', dateOfBirth: '20/20/2020', picture: { alt: 'A picture of Rogan', src: 'https://content.com', }, isHydrated: false, } validate(validator, user); // true
This does the job, and if you had some detailed unit tests in place to help this code react to interface changes; then it could be reliable - but we don't get an immediate response back, we would have to run our tests and then see it fails.
interface UserValidator { name: StringRule, dateOfBirth: StringRule, picture: { alt: StringRule, src: StringRule }, isHydrated: BoolRule } const validator: UserValidator = { name: { required: true, type: String, }, dateOfBirth: { required: true, type: String, }, picture: { alt: { required: true, type: String, }, src: { required: true, type: String, } }, isHydrated: { required: false, type: Boolean, } }; interface User { name: string; dateOfBirth: string; picture: { alt: string; src: string; }; isHydrated: boolean; } const user: User = { name: 'Rogan', dateOfBirth: '20/20/2020', picture: { alt: 'A picture of Rogan', src: 'https://content.com', }, isHydrated: false, } validate(validator, user); // true
Even though this will work in the short term - it has created two sources of truth which will become a nightmare to maintain.
Why will it become a nightmare, let me present a question: What if the interface changes?
If the User
interface changes then we will have to go through and check the UserValidation
interface side by side and find differences.
This might sound simple for this example, but trust me when interfaces get large it can feel like finding a needle in a haystack and will slow you down substantially.
I posted the exact requirement into ChatGPT and it returned this:
const validator = { name: { required: true, type: String }, dateOfBirth: { required: true, type: String }, picture: { alt: { required: true, type: String }, src: { required: true, type: String } }, isHydrated: { required: true, type: Boolean } };
Which is correct - and is the same as the above solution.
It also went ahead and regurgitated some validator method:
function validate(validator: any, obj: any) { for (let prop in validator) { if (typeof validator[prop] === 'object') { validate(validator[prop], obj[prop]); } else { if (validator[prop].required && !obj[prop]) { return false; } if (obj[prop] && !(obj[prop] instanceof validator[prop].type)) { return false; } } } return true; }
and expanded my example, but it skipped over the typescript part, which surprised me as microsoft owns a percentage of OpenAI :O
const user = { name: 'Rogan', dateOfBirth: '20/20/2020', picture: { alt: 'A picture of Rogan', src: 'https://content.com', }, isHydrated: false, }; const validator = { name: { required: true, type: String }, dateOfBirth: { required: true, type: String }, picture: { alt: { required: true, type: String }, src: { required: true, type: String } }, isHydrated: { required: true, type: Boolean } }; const isValid = validate(validator, user); console.log(isValid); // true
In the end, though this the answer I expected - it hasn't included any types so thus falls back on the cons of first point.
So, I've given some background - let's dig into the fun part and approach this using a more typescript based solution.
Thinking back to our first problem trying to make the UserValidation object, it was tricky - but now, try write out the validation object which is given above inside the editor below.
You should see that you now as you're typing it in the editor:
You should see if you scroll down in the editor above that I've scaffolded some type utilities which help construct the final UserValidator
type, which provides the intellisense.
I'll walk through these individually and explain things conceptually and as simply as I can.
Which is now a single source of truth - as we will not be defining a separate isolated interface for validation.
interface User { name: string; dateOfBirth: string; picture: { alt: string; src: string; }; isHydrated: boolean; };
Which we grabbed from our problem definition.
interface BoolRule { required: boolean; type: BooleanConstructor; } interface StringRule { required: boolean; type: StringConstructor; }
type Primitives = string | boolean;
I have defined a type called Primitives
- which isn't all primitives but is some select fundamental types which we will be reacting to:
Is either a string ("a string"
) or boolean (true/false
).
type HandlePrimitives< T > = ( T extends string ? StringRule : T extends boolean ? BoolRule : never )
So now we're gonna start introducing some new concepts:
This type, HandlePrimitives< T >
:
T
, which could be ANY TYPE from it's perspective.T
is a string, then it returns StringRule
interface - the object which contains validation rules for strings in our problem.T
is a boolean, then it returns BoolRule
interface - the object which contains validation rules for bool values in our problem.The concepts in this type are "conditional types" and "generics".
I consider generics under the same phraseology as function definitions, the type name is the function name and the stuff in the triangle brackets are parameters.
The only caveat is that in regular functions in most cases you will define the parameters explicitly, meeting certain types.
Whereas in generics for type utilities, you can think of the parameters as "placeholder types" you need to guard and react to certain types being passed in, so how do you do that? this question leads to the next concept - "conditional types"
// words are strings, loudness is a number function speak (words: string, loudness: number) => { NoiseGenerator(words, loudness); }; // K is any type, and T is any type. type OR <K, T> = K | T;
Conditional types use the ternary syntax for their "if/else" conditions, ternaries come under this syntax:
condition ? do-if-true : do-if-false
The condition is a statement which resolves to a boolean value, if the condition is true then it hits the "do if true" and if the condition is false then it hits do-if-false.
So now you're probably wondering how you create a "type condition".
The way you check if a generic that is being passed in is a certain type is by using the extends
keyword.
T extends string
as our condition means - is the type T
that is passed in a string
, if it is a string then it will go to "do-if-true" if it isn't then it goes to "do-if-else" in our previous example.
The description of HandlePrimitives is that:
T
, which could be any type from its perspective.T
is a string, then it returns StringRule
interface (from our scenario at the start).T
is a boolean, then it returns BoolRule
interface (similarly).type Validator < T > = { [Property in keyof T]: T[Property] extends Primitives ? HandlePrimitives< T[Property] > : Validator< T[Property] > };
Now, this is the pièce de résistance of the article and involves all 3 concepts including a fancy way to prevent a big old type and repeated definitions.
There are 5 concepts at play here:
Let me break the type down into its components:
[Property in keyof T]: T[Property]
This is called "mapping a type" - this allows you to iterate through the "keys of T" exposing each of the keys as an individual key in an item called "Property".
It is within a square bracket because we are defining the "key" of the next interface - note we are creating another type in the end of this process, which is another object with keys.
So you've got the keys of each of the items in the object, but now you want to get the values to check them, how do you do that?
You can access the "values" associated to these keys by doing T[Property]
, similar to if you was accessing an object normally.
T[Property] extends Primitives ? HandlePrimitives< T[Property] > : /*Validator< T[Property] >*/
(I commented out the else case just because I go over it after this paragraph - it's using recursion which is a topic that requires a bit of a separate going through :))
Here we are using our resolved "value" from doing T[Property]
and checking if it is either a string
or boolean
- if this is the case then we send the value's type into
our HandlePrimitives
method which we have explored previously.
And in our else case (as promised):
: Validator< T[Property] >
We use a recursion to enable us to check nested properties - this is because we can have nested objects such as the one inside picture
which have their own values inside which
can be validated.
If we weren't to use recursion we would end up with a long as fuck condition chain.
T[Property] extends Primitives ? HandlePrimitives< T[Property] > : {[NestedProperty in keyof T[Property]]: T[Property][NestedProperty] extends Primitives ? HandlePrimitive< T[Property][NestedProperty] > : /*I ain't writing any more of this*/} // you probably get the gist of why recursion here is important if you read the entirety of this // spoiler I got fed up after the first iteration
type UserValidator = Validator< User >;
Now we have gone through the inner workings, we now simply just pass in our User
interface which becomes the T
now in our Validator< T >
type.
Which now returns us our expected type, we then assign this type as the object and we now get all our benefits.
infer
keyword.Hopefully, this has opened up some opportunities and showed a cool example of how you can re-use existing interfaces to create some powerful utility types.
It helps to put in that extra work upfront rather than taking the easy way out and creating another interface, although in some cases I think it can be acceptable, but as long as you're being pragmatic and helping your future self and the team can help this, as this is just a method of communicating via types.
The methods used in this are how utility methods such as Partial
, Omit
, Pick
etc work under the hood so check out how they are defined too!
There are infinite ways of using these and this was just one - so please do as I have and create examples to share with people and inspire each other to improve.
And as always, here's some extra reading from some people that are a billion times better at writing technical stuff:
If you have any feedback just ping me on LinkedIn, I'm trying to improve in technical writing so if you have any, any feedback is appreciated :)
Have a nice day.
2023-03-24 - Typescript |
---|
Using validation as an example, I will break down how to re use your defined interfaces for other purposes, keeping them as a single source of truth. |
Think this would help others? |
|