{name:"craig",surname:"white"}

A way to re-use data interfaces in typescript

The motivation for this

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:

  • For string values (including nested ones), these should follow the StringRule shape.
  • For the boolean value, it should follow the BoolRule shape.

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:

  1. Go blindly and just try to write the solution
  2. Create an interface that represents the solution then write
  3. Use ChatGPT or Github Copilot and give microsoft some training data
  4. Create a utility type that uses the User interface to create the user validation schema type then write the solution

Let's work through some possible solutions:

Go blindly and just try to write the solution

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.

Create an interface manually that represents the solution

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.

Use ChatGPT or Github Copilot and give Microsoft some training data

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.

Typescript to the rescue

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:

  • Get type errors from the overall object saying what you're missing
  • Get intellisense as you type helping you fill it out
  • Unknown keys/values flag up immediately

So, what's making this work?

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.

The original interface (single source of truth)

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; };

Our original rules

Which we grabbed from our problem definition.

interface BoolRule { required: boolean; type: BooleanConstructor; } interface StringRule { required: boolean; type: StringConstructor; }

The type utilities

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 >:

  • Takes in a parameter T, which could be ANY TYPE from it's perspective.
  • IF T is a string, then it returns StringRule interface - the object which contains validation rules for strings in our problem.
  • IF 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".

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

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.

So, going back to HandlePrimitives type method

The description of HandlePrimitives is that:

  • It takes in a parameter T, which could be any type from its perspective.
  • IF T is a string, then it returns StringRule interface (from our scenario at the start).
  • IF T is a boolean, then it returns BoolRule interface (similarly).
All together now
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.

Caveats and possible edge cases

  • Arrays of items - they will fall into our recursion and break the type
    • You can handle this by adding more conditions and handling them appropriately, this may bring you into the land of infer keyword.
  • Unhandled primitive types - they will end up being never if the interface you passed in has a primitive it doesn't support, again you can improve this.
  • You still have to maintain/manage the utility types.
  • Can be a bit of a learning curve/ mindset shift to use.

In closing

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?