If you had to write logic to check if an object of a certain type is defined and if the object actually has any keys, a simple way to do it in TypeScript would be:
If you had to use this logic repeatedly, an ideal thing to do would be to create a function that encapsulates this logic. Written in TypeScript, this function would look something like:
Good stuff right? Let's consume this utility function and see what happens. The code snippet below shows what we see in a VS code editor when we use the utility function.
Okay, what just happened? Why are we having the error above?
If you look closely at the code snippet above, the logic inside the function is the same logic we used before we wrote the function but for some reason, TypeScript is complaining. What are we doing wrong?
How do we solve this issue with TypeScript not recognizing the correct logic contained within the
isPresentSomeObject utility function?
One way to fix this would be to use the non-null assertion operator (!) in TypeScript which is basically you telling the compiler, you know what you're doing.
While this works, I'm not a huge fan of this approach maybe because I'm lazy and don't want to type the non-null assertion operator (!) every time I have to use this function.
But seriously is there a better, more scalable way we could rewrite this? Hold that thought while we look at what the second issue with this function is.
Our utility function works with arguments of
SomeObjectType type, what if we had to use the same function with another object of a different type,
AnotherObjectType? We could refactor the utility function to support the new type by creating a union of the existing type and the new type, as shown below:
Okay, say we have to support a third type and a fourth type? Add the types to the union? Seriously? What if there was a fifth type?
You'll agree that creating a union of more types is not scalable and at this point, you may catch yourself thinking "Why not just make the argument of type
any so it can accept 'all' types?"
Err, hang on! The challenge with using type any is that we lose the type safety that comes with TypeScript. So how can we rewrite this function without having to use type
According to Wikipedia, Generics are a facility of generic programming that allows you extend a type system to allow "a type or method to operate on objects of various types while providing compile-time type safety".
Imagine you had an identity function that takes an argument of a certain type and returns the argument. If you had to create one function each for a string, Boolean and number types, your code could possibly look like the code below:
Hopefully not because the code snippet above violates the DRY principle. What we need instead is a way to reuse this logic regardless of argument type. Already seeing the similarity between this and our utility function?
Generics provides that abstraction that allows you define parameter types (to be specified later) making it possible to reuse code with different types. With generics, the identity function above becomes:
By doing this, we are telling the TypeScript compiler "hey, the argument type is of a parameter type, T, and you'll find out what T is at compile time". This allows us to pass any type without losing type-checking.
Rewriting our utility function to use generics, the function becomes:
With this, you get both the dynamism that comes with using any type and the safety that comes with type checking: double win, if you ask me.
Challenge 1 solved! Next stop, how do we get the compiler to "recognize" our utility function?
If you've used TypeScript for a while but haven't heard of type guards, you're in good company. Until we ran into this challenge, I hadn't heard of them either.
Let's imagine you have the following defined interfaces:
If you had to implement logic that would check for what type an animal is, how would you do it? One way would be to use a type assertion:
Great! What happens if more types are added to the
Animal type union?
type Animal = Cat | Dog | Goat | Horse | Kangaroo | ...
Writing a function for each new type isn't scalable. How then can we create a utility function that allows the TypeScript compiler infer the type of a variable without having to explicitly use type assertion? This is where type guards come to play.
From the TypeScript documentation,
a type guard is some expression that performs a runtime check that guarantees the type in some scope.
What does that even mean?
The gist is that with type guards, you can instruct the TypeScript compiler to infer a specific type for a variable in a certain context.
By using a feature called type predicates, type guards allow you to tell the TypeScript compiler that the type of an argument is what you say it is. This process of refining types to more specific types is called narrowing and it ensures that the variable type is the exact type you expect at runtime.
Predicates are functions that return Boolean values. Type predicates are of the form
parameterName is Type,
parameterName being the name of the function argument and
Type the argument type, and they also return Boolean values.
To define a type guard, simply define a function whose return type is a type predicate.
isCat type guard is a function that has the
animal is Cat predicate as its return type as shown below:
Note that I didn't even have to implement any 'serious' logic in the function, all I had to do was return
true because remember, predicates are functions that return Boolean values.
isCat type guard tells the type system that we've confirmed that the
animal argument passed is a
Cat, so when the compiler does its control flow analysis and sees our type guard, it says "Oh! Whatever is passed into this function has to be a Cat if it returns true".
It then narrows the argument type from
Cat, that way, we are guaranteed that what the type we get at runtime will be a structure that is consistent the
Cat interface definition.
To further appreciate type guards, let's use it in an if-else block:
Remember again that we didn't implement any 'serious' logic in the
isCat type guard, but the compiler sees the return type as a
Cat because of the
animal is Cat predicate; so it doesn't throw any error in the
if block of the code snippet above.
What happens in the else block however is more interesting.
Because the compiler expects a
Cat type in the
if block, it says whatever is in the
else block has got to be a different type - that type is the never type which is a type used for values that never occur, hence the error message you see in the
Hopefully that was clear, now let us apply type guards to our utility function and see how that solves the problem we described at the start of this writing.
Since a type guard is just a function whose return type is a type predicate, all we need to do is to modify our utility function to return a type predicate.
What type predicate however, do we use for this utility function?
Recall that generics allow you to define parameter types which then get assigned at compile time. To convert our utility function to a type guard, we would take advantage of that and make our return type a type predicate of the form
arg is T where
T is the argument type.
And now it works!
Thank you for reading. I hope reading this has raised your knowledge level about TypeScript generics and user type guards but more importantly, I hope it has stoked your interest in understanding how the TypeScript compiler works.
If you want to learn more about type guards, the type system or the TypeScript compiler generally, you may find the links in the Further Reading section helpful.
Stay safe 😊.
- If you catch yourself using type any as a way of making a piece of code more robust, think generics.
- Type guards are powerful for narrowing types, satisfying the TypeScript compiler's control flow process and guaranteeing runtime type-safety.
- Think type guards when the type you want to use is not as specific as you'd want or when you want to verify that external data is of a specific type.
- There are built-in type guards that come with TypeScript like
instanceofbut user-defined type guards can be more powerful when used right.
- Like all things powerful, user-defined type guards shouldn't be abused; they shouldn't be thought of as a way to hack the TypeScript compiler.
Level up every day