Skip to main content

Command Palette

Search for a command to run...

Type safe SQL queries with Typescript template literal types, part 2, inference and recursivity

Split strings using inference and recursivity

Updated
7 min read
Type safe SQL queries with Typescript template literal types, part 2, inference and recursivity
P

I'm a Freelance tech lead.

Don't forget to read the part 1 to understand how TLTs work.

Now that we know more about TLTs and how powerful they are, in this part and the following we are going to create a bunch of utility types designed to efficiently parse strings.

Inference & recursivity

Before going further and implementing our first utility type, we will introduce two important notions for the construction of these utilities: inference and recursivity.

Let's take an example: I want to create a type that cuts out a string every time the character "." is found.

type MyString = `user.id`;
type SplittedString = SplitOnDot<MyString>;
// SplittedString === ['user', 'id']

Let's create the type SplitOnDot using a conditional type and the infer keyword.

type SplitOnDot<T extends string> =
    T extends `${infer PartA}.${infer PartB}`
    ? [PartA, PartB]
    : [T];

The keyword infer is used here to extract any string from our string shape. If you are familiar with regular expressions, it could be seen as a capturing block (.+). So the type above can be read as: "any string followed by "." followed by any string" (don't forget, any string includes empty strings).

If the string meets this shape, it'll return an array containing the two splitted parts. If not, it returns a single entry array containing the input string.

Let's try our brand new SplitOnDot type with another string to split.

type MyString = `user.profile.id`;
type SplittedString = SplitOnDot<MyString>;
// SplittedString === ['user', 'profile.id']

Well, this isn’t what we want, is it? To solve this problem, we are going to add a little piece of recursivity in our code.

type SplitOnDot<T extends string> =
    T extends `${infer PartA}.${infer PartB}`
    ? [PartA, SplitOnDot<PartB>]
    : [T];

type MyString = `user.profile.id`;
type SplittedString = SplitOnDot<MyString>;
// SplittedString === ['user', ['profile', ['id']]]

The trick here is to recursively call the type with the rest of the string if it still meets the requirement of the extends keyword.

As you can see in the commented return, it only kind of works. Because our type returns an array of splitted strings, calling it recursively like so will nest the arrays... Luckily, Typescript comes with a syntax found in standard javascript: the spread operator. Using it will flatten our arrays.

type SplitOnDot<T extends string> =
    T extends `${infer PartA}.${infer PartB}`
    ? [PartA, ...SplitOnDot<PartB>]
    : [T];

type MyString = `user.profile.id`;
type SplittedString = SplitOnDot<MyString>;
// SplittedString === ['user', 'profile', 'id']

That's it, we just created our first real utility type. Let’s make good use of it!

How to split strings, the custom way

Splitting on "." is useful but splitting on any custom string is even more useful. Let's see how to generify our SplitOnDot type to split on any custom string.

To do so, we are going to rename our utility type and add another type parameter to it.

type Split<ToSplit extends string, Splitter extends string> =
    ToSplit extends `${infer PartA}${Splitter}${infer PartB}`
    ? [PartA, ...SplitOnDot<PartB>]
    : [ToSplit];

Simply replacing "." with our generic Splitter does the trick. As we saw in part1, using any string in a TLT will act as a blocker to enforce the string shape. You can thus use Generic types instead of a simple string to enforce whatever splitter you want!

Isn’t it wonderful? It is. But it’s not without a few caveats.

Caveats of generics and TLTs

Let's put our Split type to good use:

const accessor = 'user.profile.id';
type propertiesPath = Splitted<typeof accessor, '.'>;
// propertiesPath = ['user', 'profile', 'id'];

Pretty nice, but what happens when it is faced with an empty string?

const accessor = '';
type propertiesPath = Splitted<typeof accessor, '.'>;
// propertiesPath = [''];

What is the expected type of propertiesPath? Most people would answer [] because any empty string passed to a split function should return an empty array. It's not the case for our type, because an empty string is not the expected shape to perform its splitting on. Therefore the type uses the right part of our conditional expression and returns [ToSplit].

To fix this, we are going to add another step to our conditional type . We will refine the ToSplit generic input type.

Let's add an initial check to ensure the string is not empty and, if it happens to be empty, return an empty array.

type Split<ToSplit extends string, Splitter extends string> =
    ToSplit extends ''
    ? []
    : (
        ToSplit extends `${infer PartA}${Splitter}${infer PartB}`
        ? [PartA, ...SplitOnDot<PartB>]
        : [ToSplit]
      );

I know, the syntax is not fantastic but that's how it is!

Let's try to read this type line by line to understand it better: 1: Create a new type called Split with 2 generic arguments ToSplit and Splitter. Both must extends string.

2: Use a conditional type to check if ToSplit is an empty string.

3: If the conditional expression "ToSplit must be empty string" is true, return an empty array

4: If the conditional expression"ToSplit must be empty string" is false, chain another conditional type

5: Use this conditional type to check if ToSplit is a string of shape "any stringSplitterany string"

5bis: Use the infer keyword to allow the condition expression to "extract" the 2 parts of the string.

6: If the conditional expression of line 5 is true, return an array containing the first extracted string, and spread the result of a recursive call passing the rest of the string.

7: If the conditional expression of line 5 is false, return a single entry array containing the ToSplit generic argument.

Outch, it gives you a headache, right?

Don't worry, with a little practice you can read these types quite easily. Take a few minutes to try and modify this piece of code in your favorite IDE to see how it behaves. And move on when you are ready. Because it can get tougher.

Caveats of generics and TLTs, the tough part

You must be thinking, "hey dude, you screwed up, you put the same title twice in your post".

Haha, no. Wait, did I? No.

I added "the tough part" because we are going to add a little piece of code to our Split generic type in order to handle a specific case. It is a very simple piece of code, but it's not so easy to understand (and not so easy to explain...)

Let's take another example:

const accessor: string = 'user.profile.id';
type propertiesPath = Split<typeof accessor, '.'>;
// propertiesPath = [string];

As you can see, I explicitly enforced the type of the accessor variable.

By enforcing it as a string we "blur" the vision of our type and it's not able to understand the shape of the input string anymore. It's now just a string type, not user.profile.id.

What should be the type of propertiesPath then?

We can find a little hint by looking at the typings of a split function.

function split(str: string, splitter: string): string[];

Taking the string to split and a splitter as arguments, it returns an array of strings.

That's what we want to mimic. Not knowing the actual shape of the given string, we do not know the shape of the eventual return. Both are simply string and an array of string, we can't type more specifically in advance.

Let's translate this into our Split type:

type Split<ToSplit extends string, Splitter extends string> =
    string extends ToSplit
    ? string[]
    : (
        ToSplit extends ''
        ? []
        : (
            ToSplit extends `${infer PartA}${Splitter}${infer PartB}`
            ? [PartA, ...SplitOnDot<PartB>]
            : [ToSplit]
          );
    );

We added another conditional type. And again, you must be thinking "Why did you say "tough part" when it's just adding another condition?". Well, let's focus on the added piece of code.

string extends ToSplit

As you can see, we are testing that our string extends ToSplit and not that ToSplit extends string.

Using string as the left part of a conditional expression type allows us to check if a type is exactly a string and not a more precise type.

Using this trick, we can handle the specific case when the type to split is enforced as a string type as we saw in the above example.

Alright, we now have a way to split strings. It still doesn't solve our initial SQL issue does it? I assure you we've come one step closer. Let's keep going with the next step: trimming strings!

Thanks to Benoît , great developer, great proofreader.

Cover photo by Denis Blzz on Unsplash

R

Cool series!

The Split type (in the second-last example) recursively calls SplitOnDot, which I don't think is what you intended?

Also, I wonder, since the series ended abruptly, have you realized by now that type-safe SQL queries with string templates is not in fact possible? See TypeScript issues 33304 and 49552 - due to performance issues, it doesn't sound like this feature will be arriving anytime soon. 😥

Type safe SQL Queries with Typescript

Part 2 of 2

In this series of posts we'll see how to build a QueryParser Typescript type, relying heavily on template literal types (TLT). To get us started, let's first look at the specific syntax of TLT.

Start from the beginning

Type safe SQL queries with Typescript template literal types, part 1, the basics

The basics