The Power of type-checking: Advanced Typescript
We're going to explore several "advanced" building blocks that allow you to harness the power of the Typescript's type system. These types and features will enable you to enforce complex compile-time invariants.
Written by Matias Hernández, Full Stack Developer
You may already know that Typescript can be a significant improvement for your Javascript developer team, but, like any new language, there is a learning curve to harness its full power of it.
In this article, I will drive you through some advanced usages of Typescript that can help you achieve those data requirements or features.
If you are looking for an in-depth guide to help you understand the Typescript reasoning and why you should choose it for your project, check this previous article: Why we choose Typescript all the way through
What will be covered
We're going to explore several "advanced" building blocks that allow you to harness the power of the Typescript's type system. These types and features will enable you to enforce complex compile-time invariants.
- Generics
- Union and Intersection Types
- Typescript native utility types
- Mapped Types
- Template Literal Types
- Conditional Types
The principal value proposition of Typescript is to add some rules and order to a very loose language as Javascript leveraging the power of a compiler to act as a line of defense to catch data types related bugs. Static typing enforces contracts, which prevent certain classes of defects.
Let's dive directly into this.
Generics
The main idea of the concept of generics is to allow you to create reusable pieces of code that can work with a variety of types rather than be restricted to a single one.
You can consider generics as code templates that you can define and reuse through your codebase. Its use will provide you with a way to tell functions, classes, or interfaces what type they should use when you call it.
You can even think of the generic type as an argument of a function.
You should strive to use and create a generic function when you need things like:
- A function that can work with different data types
- Use the same data type in different places
Let's see an example use case to when and why use generics
Suppose you need a function that randomly returns an element from an array.
This function accepts an array
of type number
and returns a number
. All good here, right?.
Now, let's assume that you also need to get a random string from an array of strings. How would that function look?
Precisely, the same. The only difference is the type of argument. In the first case, you used number[]
and now string[]
.
How can you consolidate both functions that, in fact, have the same logic?
One option is to change the type of the argument to be any[]
but, by doing this, you will lose the type checking powers that Typescript gives you, so it is not a good solution.
The other option is to use generics, here you'll define a "type variable" that will capture the type provided at the time of calling the function, like:
By convention, typescript community uses single uppercase letters to declare the type variable, in this case,
T
. However, you can use any word to define it.
Now, let's call this generic function with different types.
This example explicitly passes the array type that the function uses with the <>
syntaxis.
In practice, the compiler will use type inference based on the argument, meaning that Typescript will guess the type of the generic based on the information from the argument passed to the function.
You can also use multiple generic parameters with generics by using different letters/names to denote each type.
Using generic constraints to limit types
You may want to create a generic function (or interface or class) that can accept different types but only from a subset of types. In other words, you want to constrain the types that can be used for the generic.
To accomplish this constraint requirement, you'll use the keyword extends that in effect allows inheriting functionally from a base type or class.
Let's use the same function as before, but let's say that you only want to accept arrays of strings and numbers.
Using generic constraints to limit types
Union Types
A union type (or just "union" or "disjunction") is a way to define a mutually exclusive type. It is a way to create a value that can be of more than one type at a time.
This type provides information to the compiler that helps it to prove that the code is safe for all the possible data types passed to it.
This type is a perfect fit when you know all the possible states will be but do not know for sure which one will be used at a certain point. One everyday use case for this type is defining a dispatcher's actions like in the reducer pattern (like Reduce or useReducer
in React).
Let's think in a promise, a promise can be in 3 states, pending
, fulfilled
or reject
you can model these states like
In this example, there are 3 interfaces: fulfilled
, Rejected
and Pending
that model the 3 states of the promise, then there is a type declared named RequestState
that states that it can be any of the 3 possible states (one at a time).
Finally, in the function doRequest
, the request is performed, and a state is stored in a variable named state
. This state
is then return by the function so in effect doRequest
return type is Promise<RequestState<MyData>>
.
Then you can use this in another function and just switch
over the state of the request by accessing the state
value here. Typescript will ensure that you cover all the possible causes.
Here is the Demo, go ahead and try to delete one of the switch cases. What happens?
Another common use case of an union type is found in the DOM itself. DOM events always happen independently of each other. So there is a finite list of possible events that can be processed:
One particular case of union type is discriminated unions (or tagged union). This specific case allows you to easily differentiate between the types with it.
Why would you want this? Because a union type can also host different types and sometimes you will want to know what type is in use, you can always use type guards for this purpose, but there is a shortcut: discriminated unions.
To create a discriminated union, you need to add a specific field to each type in the union, differentiating between each type.
One everyday use case for this type of union is to check that one piece of the data is present while another part is not. For example, A user account should have username
and email
and include an avatar
OR an `emoji.
To model this, you need to combine different types to define each case and use optional attributes along with the never
type like this:
Here you can find the Demo of this code
To implement the above requirement, there are 4 types that work in pairs to describe what attribute should be present and how the other attribute should be.
Then a new type is created as the union of the other ones.
Then the new type is used, and Typescript will tell you if you are using the wrong combination of attributes.
Union types are an excellent modeling tool. There are good reasons to not use them:
- When the types are known at compile-time: You can use generics instead to provide further type safety and flexibility.
- When we need to enumerate all possibilities at runtime: Use enum instead for this).
Union types are an ergonomic way to model a finite number of mutually exclusive cases, allowing you to easily add new cases.
By combining this feature with mapped types, you can even dynamically create types as shown in this great Demo by Matt Pocock that transform a union type to another union type with a different shape.
Intersection types
Intersection types can be considered the opposite of the previous union type.
These types create a new type by combining multiple existing types. The new type will have all the properties and features of the existing ones.
When you are intersecting type, the order doesn't matter.
In summary, an intersection type combines two or more types to create a new type that includes all the properties of the existing types.
Typescript native utility types
Typescript provides you a handful of handy utility types that helps to manipulate types with ease. These utility types are, in fact, generic types.
Partial
This utility type will enable you to create a type that will set all the properties of the exiting type to be optional.
Required
It is, in fact, the opposite of Partial, marking all the properties of a type as required.
Readonly
This type will mark all the properties of the type argument to be not-reassignable or read-only
Pick
You can create a new type by "picking" properties from an existing type.
This type allows you to create new types from a previous one by describing what properties you want as the second argument of the generic parameters.
Omit
On the other hand, you have Omit that works as the opposite of Pick by allowing you to select what elements to remove or not consider when creating the new type.
Extract
This type will create a new type based on the properties present in two different types. It will extract from the type T all properties assignable to type U.
Let's see an example. Here the goal is to extract all the arguments that are function types.
The first argument we pass to Extract is our union type, and the second argument is the type that we will use when comparing each union member. If a member is assignable to our second argument, it will be included in the resulting type.
But, how this looks like in real life? A good use case will be creating a filtering function.
Say you have an array of users with different types of users tagged by the type
property, so you have a discriminated union as a type.
Now, you want to create a function that takes an array of Users
and returns only those that match a specific type
.
You can quickly get away with something like this.
But, there is a minor issue with this function (even though the result will be correct at runtime), the return type of this function will always be Users[]
even though you know for sure that the result will be just one of the types.
Let's use Extract
to create a better return type.
This seems a bit more complex so let's dissect it.
The function accepts two generics and uses a type predicate inside the filter function, allowing you to instruct Typescript about the specific type of the argument passed to the function when the function returns true
.
The type that the filter function will narrow is Extract<T, Record<"type", U>>
where T
will be the Users
array and Record<"type", U>
will be an object like {type: 'affiliate' }
So the end result of the filter will be {type: 'affiliate', program: string}
Exclude
This type works opposite to Extract by excluding properties already present in two different types. It excludes from Tall fields that are assignable to U
.
NonNullable
This utility type will take a Type T
and remove null
and undefined
from the allowed type values.
useful for instances where a type may allow for nullable values farther up a chain, but later functions have been guarded and cannot be called with null
It is important to note that this utility is not recursive. If you have a type with a nullable property, using the higher NonNullable
will not affect the property. You'll need to overwrite the base type/interface to do so.
There are a few more utility types that allow you to perform different tasks like:
- Parameters: Create a tuple type from the types used in the parameters of a function
- ReturnType: Creates a type from the return type of a function.
- String manipulation types: Uppercase, Lowercase, Capitalize, and Uncapitalize. These utility types allow you to manipulate strings transforming string types.
Mapped types
It is time to review a feature a bit more complex. Mapped types allow you to take an existing model and transform its properties into a new type. This handy feature allows you to keep your types DRY.
The first step to understanding this type of metaprogramming is to know why you want to use mapped types.
The primary use case is when you need to create a type derived from another type and remain in sync with the original one.
For example, a permissions schema defines an object that stores the user's permissions to change configurations in the app.
What happens if you add new configurations to the schema? You'll need to also update the permissions type to accommodate the new configuration on it.
In this case, would it be way better to have types that rely on each other to keep them in sync, decreasing the maintainability effort?
How could this look?
This example combines several features of Typescript, it uses:
- Indexed access types: You can access the property type by looking it up by name.
- Index signatures: Allow you to access a property even if you don't know the name of it by using the
[]
syntax (like you usually do with dynamically accessing an object property in javascript) - The keyof operator: This operator returns a union of the keys of the type passed to it. In this case, the use of
keyof Configuration
resolves to"layout" | "withdraw" | "deposit"....
- Capitalize An utility type that transforms a string into the capitalized version of it. In this case, it changes the
Property
name:layout
→Layout
. Finally, it uses template literal types to create the name of the new property. This is a feature name as key remapping.
Template Literal Types
These types are built on the string literal types adding the ability dynamically creates a string. It uses the same syntax as javascript template literals.
But the real power of this type comes when you need to define a new string type by deriving its value from the information inside the type.
Let's use template literals to extract and manipulate a string literal type to, in turn, use them as properties, let's start by splitting a string to an object.
Imagine that you want to check if a semver string is in the correct format. You know the semver pattern is like Major.Minor.Patch
so, let's build a type to check that pattern and at same time split that into an object for type checking and autocompletion.
First step is to get the string version
into a type by using typeof
. Then create a type to hold the extracted semver pattern.
First step is to restrict the generic Semver
to be an string, then at the right side a conditional type is defined (explained below) that check if the pattern match, if so, then the pattern is split into an object. If the condition fails, then an error is stored.
There are many other powerful applications of this features, for example:
- A JSON parser with type safety Jamie Kyle 🏳️🌈 on Twitter
- A dot notation getter with type safety GitHub - ghoullier/awesome-template-literal-types: Curated list of excellent Template Literal Types examples
- A CSS Parser that you can toy around in the typescript playground.
- And many more that you can find in this awesome repository. There is also a few collection of utilities that help you to create even more complex types, one of them is ts-toolbelt that provides hundreds of utilities.
Here you can find a very cool use of the utilities of ts-toolbelt and string manipulation. (video by Matt Pocock)
One of the utilities used in that video and provided by ts-toolbelt is Split
. Yes, you can construct a type that let's you split a string literal and transform that into a list of string literals to then use it as type.
Let's see how to build a simplified version of it
First, it declares that the type will accept two "arguments", type S
as the string to be splitted and type D
as the delimitator, both should be strings.
Then, in the second line, it use a conditional type (explained below) that checks if the string is an string literal, if so it returns an string array.
Next line check if this string array is empty, if so, it returns an empty tuple, otherwise, in a similar way to the ExtractSemver
example it checks if the string S
matches the pattern and extract the pieces to then recursively re-run the Split
. If the string don't match, meaning the delimitator is not present, then it just return the S
.
Conditional Types
Another powerful typescript feature is the ability to create a type based on a condition. This is, in fact, the description of a type relationship test that selects one of two possible types based on the outcome of that test.
It looks like this
That looks similar to the javascript ternary operator. You can also read this like this pseudocode.
Here the letters T
, U
, X
, and Y
stand for arbitrary types.
The piece of code shown in the conditional part T extends U
describes the type relation that is set to test.
Typescript uses this feature to create the utility types like NonNullable
. The code for that type looks like this
This type selects the never
branch if the type T
is assignable to either null
or undefined
. Otherwise, it keeps the original type T
.
Let's check another example use case for conditional type. Imagine that you want a way to figure it out if a string is trimmed, meaning it doesn't have empty spaces at the end of the beginning. How can that be done?
Now let's see how to implement this type. Let's check if the passed string has empty spaces at the end. You'll need a way to say:
"Check if the string contains an at the end. This can be done by using the keyword infer
The infer
keyword is a compliment to conditional types. It cannot be used outside of an extends
clause. infer
allows you to define a variable within the constraint to be used or referenced later.
So in the previous code snippet, the type infer R
is extracting the type of the T
generic and, since it is inside of a template literal, is constructing a new string with an empty space at the right side.
It can be read as "Is T the string with empty space at the right"?
With that, let's create the type TrimEnd
.
So, this type accepts a generic that extends
(is constrained to) a string
. A conditional type checks if T
is the string with empty spaces at the right side (this is done dynamically thanks to the template literal type).
If the condition is true, it passes the type again to recursively find a string that doesn't end with a space.
You need to check if the string starts with an empty space. That type is the same but opposite of TrimEnd
, let's call it TrimStart
And with these two types, you can finally create the T'rimmed' type required.
This type uses a combination of powerful typescript features: Template Literal Types and Conditional Types. You can mix and match as many Typescript features as you need and want to create your own utilities and complex types.
Typescript is a fully-fledged language that is not just to add some types into the loosy Javascript but can help you model complex data requirements and avoid various kinds of bugs.
Join
Cleverdevelopers
Want to peek into our daily work? Our coaches recount real world situations shared as learning opportunities to build soft skills. We share frameworks, podcasts and thinking tools for sr software developers.
The (remote) opportunities
We expect professionalism and client service, so we can offer a deeply caring experience for our clients. In return, you get freedom to work wherever you want. No timesheets, no big brother watching every move. We trust you to know what’s best to find the right solution.
Don't see what you're looking for? Use our general application form