At first glance, the never type does not sound very useful for everyday coding. But actually, the following property of the type comes in very handy:
[…] The never type is assignable to every type; however, no type is assignable to never (except never itself) […]
With a small hack, this can be used to increase the type safety of a code base with no risk of breaking existing code and without any runtime overhead.
Problem
Maybe you have lots of places in your code, where you use some data like an userId, an email, a firstname or whatever. You could just use the standard types for that (string and so on):
getUserByUserId(userId: string): Observable<User>;
But this approach has some problems:
- You could accidentally call this method with the wrong parameters, e.g. with an email instead of an userId.
- This error would only be found during runtime, not compile time.
- Sometimes during refactoring your codebase you want to find all places where a field like a userId is used. This is very hard if it has a simple type, but very easy if it has a custom type.
Solution
The problems described above disappears, if we don’t use the built in string type but create a custom type for the userId:
getUserByUserId(userId: UserId): Observable<User>;
Now it’s easy to find all places where this UserId type is used. And is not possible to accidentally call this method with an email because the latter has another type.
Where does the never type come into play?
How to implement this new UserId type? The naive solution does not work out:
export type UserId = string;
getUserByUserId('some string'); // no compiler errors here :-(
The problem is that for typescript string
and UserId
are the compatible (because at runtime they are the same). We need to change the UserId
type, so that they are (at least at compile time) not compatible anymore in order to get compiler errors. We could create a new UserId
class to achieve that. But this might break existing code, which you forgot to migrate and which still expects to get a string, at runtime. But with the help of the never type you can define this new type like this:
export type UserId = string & {
// name of the following field needs to be unique. If it
// were not unique and you would reuse it for another type,
// both types would be compatible!
____doesNotMatter_UserId: never;
};
// you can't create new values of type UserId by hand, because
// you cannot assign anything to never. so you'll need this
// converter function:
export const toUserId = (userId: string) => userId as UserId;
This solution has some nice characteristics:
- It’s not possible to assign string values to variables of type
UserId
anymore, because they are missing the____doesNotMatter_UserId
attribute. The compiler will complain about that if you give it a try. - To convert a string to the new type, you need to cast latter to it. Creating a value of this type another way is impossible because you cannot assign anything to never. Normally you’ll have to do this in your tests, during url parameter parsing or (maybe) when converting your REST resources.
- I write a factory method like this for the casting normally: export
const toUserId = (userId: string) => userId as UserId;
. This factory method is nice, because now you can search for the places whereUserId
values are created. - A big advantage is that this trick does not change the runtime behavior of your code. The sanity check is done only during compile time. Because it’s still a
string
during runtime, nothing bad will happen if you forget to migrate a place in your code (and which expects astring
in our example above). - It can be used for other types too, not only strings.
So let’s see this type in action:
export type UserId = string & {
// name of the following field needs to be unique. If it
// were not unique and you would reuse it for another type,
// both types would be compatible!
____doesNotMatter_UserId: never;
};
// you can't create new values of type UserId, so you'll need
// this converter function:
export const toUserId = (userId: string) => userId as UserId;
// an example function which expects our new type
export const isSuperUser = (userId: UserId) =>
userId === 'superuser';
// a function we forgot to migrate and which still get's a string
export const unmigratedIsGuestUserFn = (userId: string)
=> userId === 'guest';
// some examples
const aUserId: UserId = toUserId('u1111');
isSuperUser(aUserId); // works and return false
isSuperUser('superuser'); // Compile time error: Argument of type
// 'string' is not assignable to parameter of type 'UserId'.
// Type 'string' is not assignable to type '{
// ____doesNotMatter_UserId: never; }'.
unmigratedIsGuestUserFn(aUserId); // no compile time errors
// because at runtime our new type is just a string :-).
// So this type refactoring is very safe
// warning - don't reuse your ___doesNotMatter attributes:
export type Email = string & {
// DO NOT DO THIS:
____doesNotMatter_UserId: never;
};
// Email and UserId are compatible because they have the same
// signature, so we don't get compiler errors here:
const email: Email = toUserId('superuser')
isSuperUser(email);
// So -> always use unique names for these
// ____doesNotMatter attributes
Conclusion
I found this approach to be especially useful, if values of a type are used in many places but are created only in few cases. For instance, I expect that I would only need to call the factory function toUserId to convert strings to the new UserId type during:
- converting a rest resource to our domain model (should be one place)
- parsing a url parameter (should be few places)
- in tests But there might be lots of places, were a function gets a value our new type as parameter and just passes it around. So through this approach I get the former described benefits of a richer type model.
Acknowledgements
I learned about this trick during an Angular meetup held by Thiele Leonard in 2020. I found a recording of the same „Angular at scale“ talk hold on a different occasion, which contains many more tricks, so have a look.
Top comments (0)