TypeScript's Powerful Type Inference with Conditional Types and String Literals
Let's explore how you can use Typescript’s conditional types and template literals to create type algorithms.
In the last article, we laid the groundwork by:
- creating type annotations for your function arguments and return types
- using native utility types to perform type transformations to accommodate your personal requirements
- got a handle of Typescript Generics.
It's time to go to the next level!
By the end, you'll be well on your way to writing your own powerful branching logic on the type-level.
Let's get started.
Why would you want to write algorithms in the type level?
Every programming language was built to create solutions by using different algorithms, and Typescript is no different in that regard. The only difference is that the code you write only affects the type level (build time), helping you to check the safety of your code.
So, creating type algorithms can help you increase the type safety and accommodate your code to more complex requirements.
What type of algorithms can you write?
The most basic algorithm you can write is code branching. This is, using conditional blocks to allow the data flows to one or other situation based on certain conditions; you know, the old-good if-else
block.
The TypeScript type system is Turing Complete and therefore it also implements code branching strategies, but in a slightly different way.
What does Turing completeness mean?
In general terms: a language or system that is capable of solving any computable problem given enough time and space. Typescript is able to do this, so you can implement solutions to any problem.
Typescript Conditional Types
The code branching strategy in Typescript is known as Conditional Types this is, you will define a type whos value depends on some calculation.
TypeScript’s Conditional Types are what is used for code branching. They are a type whose value is determined by a calculation:
The syntax can be confusing, but it is actually quite similar to a concept you may already be familiar with from the JavaScript world: the Ternary Operator.
The above example can be read as "If Condition then return true
, return false
otherwise". The Condition part is what often trips up newcomers, as it is somewhat strange.
Every conditional type uses the extends
keyword to implement the condition side of the ternary operator. In this case, SomeType extends BaseType
can be read as Is SomeType
assignable to BaseType
.
We previously discussed the behavior of the extends
keyword in a previous article in this series, so be sure to review it!
Learn the Key Concepts of TypeScript’s Powerful Generic and Mapped Types
The type system is actually based on mathematical concepts. You can represent the idea of one type being assignable to another as a concentric circles diagram.
If the type used on the left-hand side of the extends
keyword "belongs" to or is a "subset" of the type used on the right-hand side, then the condition is deemed true
and the first branch of the conditional type is returned.
Let's create a simple conditional type that will check if a string have the shape of an URL:
Check the Typescript playground to play around
In this example you can see how the power of conditional types is unleashed when is used in combination with Generics and other Typescript features like string literal manipulation.
The above example can be read as the following:
IsUrl
is a "type function" that accepts a Generic parameter namedUrl
.- Condition: Does
Url
"match" (is assignable to ) the described string literal? - The string literal side: The right side of the condition is a string literal "shape" that describes how a URL string looks. Start with
https://
followed by any string, then adot
followed by any string. - If the Condition passes then "return" the
Url
- If the Condition does not pass, then "return" an error message.
This type can be used to check if a string literal matches the format of a URL string, such as in the following example:
Conditional types in Typescript are written using the same syntax as the Javascript ternary operator. This is the only way to use code branching since Typescript's type system programming is a functional implementation, which means code must be written using expressions.
So, the ternary operator is the right way to go if you need to implement multiple conditions. However, you can also write nested conditions if you need to. But this can become quite messy:
The code snippet above comes from tanstack/router source code.
It uses a nested conditional type with inference, template literal pattern matching, and recursion to determine the type of a path string.
Pattern Matching
The previous code sample introduced an utility type called IsUrl
, which is an advanced use of conditional types that checks if a value matches a certain pattern. This is known as Pattern Matching.
The concept behind pattern matching comes from computer science and is an integral part of the functional programming paradigm. It's described as "the act of checking a given sequence of tokens for the presence of the constituents of some pattern."
Check out this wikipedia entry to read more about the computer science concept!
We are using this feature to check if a type value extends the pattern, in the previous case a string literal pattern. But the pattern can be any type. For example:
It is important to note that this is type-level pattern matching, which allows you, in combination with conditional types, to perform code branching based on how the condition is evaluated. For more information, check out the Wikipedia page on the computer science concept.
Template Literals
This will be a brief overview of template literals since we will need a full-leght article just to describe the full feature.
Another feature used in combination with conditional types are template literals.
Coming from Javascript programming, I'm sure you are used to using template literals to handle your string needs, like concatenating and interpolating strings and variables.
The syntax used here is the same as the one used in runtime code, with backticks and curly braces for interpolation. From the previous demo:
Here, the Template
type is the template literal that describes the shape of a string. We are interpolating something that can be strange at first glance since is not a value but the primitive type: string
.
The usage of ${string}
describes any string. So, Template
describes the combination of all possible strings that starts with https://
and have a dot in the middle.
This is really powerful for typing function arguments. You can define a template literal type that describes exactly the shape of string the function requires.
The UUID template literal is a simplification of the real UUID specification.
In the example, the second implementation of fetchUser
can be considered better since the only type of argument that can be passed are the ones that resemble a uuid
.
Type Inference
You have now learned how to create a conditional type and combine it with Generics, and template literals.
But there is more!
You can extract "pieces" of data from the types to be used in your conditional algorithm:
Go ahead and check the playground link to play around with the implementation
The example above implements a utility type that retrieves or extracts the domain string from a URL. It is very similar to what we previously did with the IsUrl
example:
- The type receives a Generic named
S
. - The condition defined checks if
S
matches with the defined template literal. - The template literal is defined as
https://${string}.${infer Domain}
- If
S
matches with the template then it will "return"Domain
. Otherwise, it returnsnever
.
The new part here is the 3rd step. The template literal definition includes the new keyword, infer
.
The infer
keyword enabled the creation of inline type variables. This type variable will hold some data that is assigned by destructuring the type passed in the left side of the extends
.
With infer
you will create a new Generic that can be named anything you want.
Typescript will be in charge to perform the assignation of the type.
One caveat of the infer
keyword is that the generic variable created can only be used in the truthy side of the code branching.
Let's see another example:
As always, check the playground here
This example defines a type that allows you to check that an array is not empty, to do so it use a conditional type plus two other features:
infer
keyword to extract the first element of the array- and Variadic Tuples
The type can be read as If A is assignable to an array that contains at least one element then return true
. Otherwise, return an error message.
The infer
keyword is also used to create some of the native utility types like Parameters
The example re-implements the utility type Parameters
that takes a Generic F
and, If F
is a function, it will then return a tuple with the parameters of the function.
The demo
Let's review a full demo of a utility type that relies on Conditional Types, infer
keyword and recursion.
We’ll build a simple query string parser, that will:
- Read a URL string.
- Perform some transformations.
- Get the query string data as an object type.
Visit the playground to read more about the implementation
That looks complex right? But at this point we already reviewed all the concepts required to read and understand the snippet:
- The first type defined performs a pattern matching task using a simple template literal and extracts the query string part of the URL using
infer Q
. - An internal utility type
_Merge
is then defined that simply takes two object types and merges (and overwrites) their properties into a new object type (using mapped types). - Another helper type is created that will split a string
S
based on a separatorS
usinginfer
and pattern matching to return a new Record as key/value pair. - Lastly, it creates a new Conditional Type that takes a query string and, using
infer
, extracts the key/value pair, then merges them into a new object type.
This example helps to showcase the power of the type level algorithms that can help you create complex types to handle your data requirements.
Summary
In this article we reviewed one of the most exciting features of typescript, the one that allows you to implement algorithms on the type level.
Pandora's box has been opened for you to implement solutions with your own complex requirements!
- Conditional types are implemented using a ternary operator
- Here the
extends
keyword is used to create the condition part of the conditional type. - Conditional types can be used to peform pattern matching.
- Conditional types can be used to "extract" type variables using the
infer
keyword.