The title may sound something like an oxymoron, so perhaps some clarification is needed.
I freqently deal with operations against databases. When I mention that I increasingly prefer using a functional style, in particular, using a query builder directly as opposed to an ORM – one of the primary concerns brought up by people is that ORMs can ensure that data validations are performed for every operation that performs a mutation against the data source.
This post is not much of a rant against ORMs. It is a practical outline of how we can utilize types to ensure (at compile time) that our data access layer only allows validated data. Even if the actual validation happens at runtime.
Let’s say we have a User
type:
1 2 3 4 5 6 |
interface User { name: string; email: string; } |
Now assume that we have a function enrollUser
where we only want to allow valid users. If our function takes a User
object:
1 2 3 4 5 |
export const enrollUser = async (user: User) => { // ... enrollment logic } |
In the above, it is possible for this function to be called with either a valid or an invalid user and it is up to consumers invoking this function to ensure that this is called after validating, which can be error prone in larger applications.
So, lets try to enforce at the type level that this function will only consume valid users. We can do something like:
1 2 3 4 5 6 7 |
type Valid<T> = T & { _isValid: true } export const enrollUser = (user: Valid<User>) => { // ... enrollment logic } |
Now, our validator functions will have to tell the compiler that a user is indeed valid:
1 2 3 4 5 6 7 |
export const validateUser = (user: User): user is Valid<User> => { if (!user.email || !user.email.match(/^\S+@\S+$/)) return false; // (Don't use that regex in real applications). return true; }; |
validateUser
here acts as type-guard telling the compiler if the user complies to Valid<User>
type.
So, now if we try to invoke enrollUser with a user that has not been validated we will get an error:
But if we validate it first, the error goes away:
1 2 3 4 5 |
if (validateUser(user)) { enrollUser(user); } |
There is one caveat with this approach though:
Our type-guard is lying to the compiler and the compiler believes that lie unconditionally:
Here TypeScript is telling us that we have an _isValid
property in user but that is not exactly true, because that property will not be present at runtime. So if someone comes to rely on a runtime check on that property, the check will fail at runtime. So effectively we have traded possibility of one runtime fault for another.
We can do a couple of things to prevent this. The obvious one is to actually have a _isValid
property in the object. Or to wrap the type of objects being validated (so that the primary record type can be directly serialized):
1 2 3 4 5 6 7 8 9 |
interface Validated<TTarget, TValid extends boolean> { target: TTarget; isValid: TValid; } type Valid<TTarget> = Validated<TTarget, true>; type Invalid<TTarget> = Validated<TTarget, false>; |
However, ff we don’t want any runtime overhead at all, instead of a string property we can use a symbol:
1 2 3 4 5 |
declare const isValid: unique symbol; type Valid<T> = T & { [isValid]: true }; |
The benefit of using a symbol is that, as long as this symbol is not exported from the containing module or namespace, it will not be possible to use it at runtime. TypeScript 2.7 release announcements go into more details on type level treatment of unique symbols.
No other symbol (even if they have the same name) will be equal to this symbol. This will hold true even if Symbol
is polyfilled.
TypeScript autocompletion will not show this as a completion candidate, eliminating the possibility of accidental usage: