Using phantom types to extreme type guard

Matheus Vellone
3 min readJul 20, 2023

--

Lets pretend you have an entity User with an id property which is a string prefixed by u_ then followed by 5 random characters, 7 in total. The simplest way would be to type it like this. Now lets create a function that have a User ID as a input

And now we declare our function that will receive a User ID and do something with it

The problem here is that our ID definition is too wide and accepts anything like random string or u_123 as a input as seem in this TS Playground

I wonder if there is a way to narrow the type so it only accepts strings prefixed with u_ with 7 characters total 🤔

We could set the id type to something like type User = { id: `u_${string}` } which would lock the prefix for us, but there’s no way to check the length of a string with typescript. If you only need to check the prefix, this is the simplest way to do it.

This is where phantom types comes in handy. We can extend the base string, or number, type by adding a custom property which locks the type to the Entity itself. We make this “lock” by using phantom types with Type & { __idTypeFor: Entity }

Now we have a specific type to describe an User ID.

But now, calling the previous loginUser method is broken, because we cannot do loginUser('u_12345') anymore, because Argument of type ‘string' is not assignable to parameter of type ‘ID<string, User>’, which makes sense because u_12345 is not guaranteed to be a valid user by our rules (prefix and length).

We could do loginUser('u_12345' as ID<string, User>) but we would be vulnerable to wrong formatted IDs again. Instead of using as to cast our type, we can create a function to validate the prefix and the length to be sure that the argument is a valid ID while also inferring the type of the ID. Like loginUser(validateUserId('u_12345'))

We could also add a validation step at the first line of loginUser function, but we would be paying a runtime cost of ALWAYS running the validation, even if the ID is already validated previously.

First we need to declare our rules and link them into our Entity type. We accomplish this by using phantom types again Options & { __modelTypeFor: Entity }.

With the options configured, we now need a way to validate a input based on the UserIdOptions and if valid, return the correct type for it based on the Entity type inferred from UserIdOptions.

There is a lot of ways to accomplish this, but I’ll share the way I use it: with zod, a TypeScript-first schema validation with static type inference. I created a generic helper function to get a zod schema based on the IDOptions received.

You can extend the idRule function to validate more rules you might have in your ID

With all of this, we can now see our full example:

Also available in CodeSandbox, with type hints

I’ve added a index2.ts in the CodeSandbox example with more practical use cases I have in my project which might be useful for you too.

And with all of this, we can now rest assured that we won’t see any error from:

  • Missing prefix IDs
  • Lack of characters or extra ones
  • Any other rule you might want to implement: suffix, fixed parts, algorithmic validations and so on…

Hope this helps you. Peace ✌️

--

--

No responses yet