TypeScript's Powerful Type Inference with Conditional Types and String Literals

Matías Hernández
author
Matías Hernández
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:

type ConditionalType = SomeType extends BaseType ? true : false

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.

https://res.cloudinary.com/matiasfha/image/upload/f_auto,q_auto/v1671290442/Screenshot_2022-12-17_at_12.19.57_lukk5s.png

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:

import type { Equal, Expect } from '@type-challenges/utils'
const str1 = "<https://matiashernandez.dev>"
const str2 = "htpss://not-valid-url"
type IsUrl<Url extends string> = Url extends `https://${string}.${string}` ? Url : `${Url} is not a valid URL String`

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 named Url.
  • 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 a dot 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:

function parseUrl<Url extends string>(url: IsUrl<Url>) {
}
parseUrl(str1) // OK
parseUrl(str2) // Fails: Argument of type '"htpss://not-valid-url"' is not assignable to parameter of type '"htpss://not-valid-url is not a valid URL String"'.

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:

type CleanPath<T extends string> = T extends `${infer L}//${infer R}`
? CleanPath<`${CleanPath<L>}/${CleanPath<R>}`>
: T extends `${infer L}//`
? `${CleanPath<L>}/`
: T extends `//${infer L}`
? `/${CleanPath<L>}`
: T

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:

type IsPost<P> = P extends {title: string; author: Author } ? true : false type t1 = IsPost<{title: "This is the title", author: { name: "Matias"}}> // true type t2 = IsPost<{title: "This is the title"}> // false

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:

type Template = `https://${string}.${string}`
type IsUrl<Url extends string> = Url extends Template ? Url : `${Url} is not a valid URL String`

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.

// This accepts any string as userId
function fetchUser(userId: string) {
}
// This accepts only strings with the correct uuid shape
type UUID = `${string}-${string}-${string}-${string}`
function fetchUser(userId: UUID) {
}

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:

type ExtractDomain<S> = S extends `https://${string}.${infer Domain}` ? Domain : never
const str1 = '<https://matiashernadez.dev>'
const str2 = '<https://matiashernandez>'
type test1 = ExtractDomain<typeof str1> // 'dev'
type test2 = ExtractDomain<typeof str2> // never

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 returns never.

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:

type NonEmptyArray<A extends any[]> = A extends [infer First, ...any] ? true : "The array cannot be empty"
type test1 = NonEmptyArray<[1]> // true
type test2 = NonEmptyArray<[]> // The array cannot be empty

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

type MyParameters<F> = F extends (...args: infer Args) => any ? Args : F
type fn1 = (arg1: string, arg2: number) => number
type fn2 = (obj: { arg1: number, arg2: boolean}) => boolean
type noFn = { arg1: number, arg2: boolean}
type t1 = MyParameters<fn1> // [arg1: string, arg2: number]
type t2 = MyParameters<fn2> // [obj: {arg1: string, arg2: number}]
type t3 = MyParameters<noFn> // {arg1: number, arg2: number}

Link to playground

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.
const route = "<https://mysite.com?query=123&user=2&test=some> string"
type ExtractQS<R extends string> = R extends `https://${string}?${infer Q}` ? Q : never
type _Merge<O,P> = {
[K in keyof O | keyof P]: K extends keyof O ? O[K] : K extends keyof P? P[K]: never
}
type SplitToObject<S extends string, Separator extends string> = S extends `${infer Left}${Separator}${infer Right}` ? Record<`${Left}`, Right> : S
type ExtractQSParameters<QS extends string> = QS extends `${infer Var}&${infer Rest}` ?
_Merge<SplitToObject<Var,"=">, ExtractQSParameters<Rest>> : SplitToObject<QS,"=">
type RouteParameters = ExtractQSParameters<ExtractQS<typeof route>> // { query: "query"; user: "user"; test: "test"; }

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 separator S using infer 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.