TypeScript string literal type concatenation
Tags: typescript, tutorial, programming
Published: 2022-07-01 09:22:01

Recently I had run into a situation where I had a few string constants that I wanted to join into a single value, constant types in TS contain the literal value assigned to them:

```ts
const a = "foo"; // Inferred type: const a: "foo"
```

However when joining the constants the type is narrowed down to a regular `string`, even though the content of the string is known at compile time:

```ts
const a = "foo"; // const a: "foo"
const b = "bar"; // const b: "bar"

const combined = `${a}${b}`; // Inferred type: const combined: string
// What you'd expect: const combined: "foobar"
```

## Solution

To keep the literal types in the joined string I've used a custom join function:

```ts
function join<T extends string[]>(...strings: T): Concat<T> {
  return strings.join("") as Concat<T>;
}
```

With this the generic type T is an array of string literals that is passed to the join function. All that is needed now is to implement the `Concat` utility type that will take an array of string literals and turn it into a single string literal, like so:

```ts
type R = Concat<["foo", "bar", "baz"]>; // type R = "foobarbaz"
```

A naive implementation of this type could be to simply hard-code a type concatenation for every possible array length:

```ts
type Concat<T extends string[]> = T["length"] extends 1 
  ? T[0] 
  : T["length"] extends 2
  ? `${T[0]}${T[1]}`
  : T["length"] extends 3
  ? `${T[0]}${T[1]}${T[2]}`
  : T["length"] extends 4
  ? `${T[0]}${T[1]}${T[2]}${T[3]}`
  : T["length"] extends 5
  ? `${T[0]}${T[1]}${T[2]}${T[3]}${T[4]}`
  : never;
```

This is however very limited, since there's a limit on how many string literals you can concatenate this way.

### Concatenation via type recursion

To allow for an arbitrary number of string literals we can use inference, array type destructuring and recursion:

```ts
export type Concat<T extends string[]> = T extends [
  infer F,
  ...infer R
]
  ? F extends string
    ? R extends string[]
      ? `${F}${Concat<R>}`
      : never
    : never
  : '';
```
How does it work?

First `T extends [infer F, ...infer R]`, retrieves the first element of the T array and assigns it to F, and takes the rest of the elements and assigns those to R, so for example given a T of type `["foo", "bar", "baz"]`, F will become `type F = "foo"` and R will become `type R = ["bar", "baz"]`.

Then `F extends string` and `R extends string[]` ensure the F and R are of the types that we expect, those conditions should always be met and it shouldn't be necessary to write them, however the TypeScript engine will error out without it.

Finally a string literal type is returned with F at the beginning of the string literal, followed by the result of `Concat<R>`.

This type utility paired with the generic `join` function from earlier gives us a great tool that can join strings constants and retain the string literal type.

## Bonus

In the TypeScript version 4.7 and upwards the Concat utility type can be simplified like so:

```ts
export type Concat<T extends string[]> = T extends [
    infer F extends string,
    ...infer R extends string[]
  ] ? `${F}${Concat<R>}` : '';
```

And here's a join function and Concat utility type with a separator:

```ts
export type PrependIfDefined<T extends string, S extends string> = T extends "" ? T : `${S}${T}`;

export type ConcatS<T extends string[], S extends string> = T extends [
    infer F extends string,
    ...infer R extends string[]
  ] ? `${F}${PrependIfDefined<ConcatS<R, S>, S>}` : '';

function joinWithSeparator<S extends string>(separator: S) {
    return function <T extends string[]>(...strings: T): ConcatS<T, S> {
        return strings.join(separator) as ConcatS<T, S>;
    }
}

// Usage
const result = joinWithSeparator(":")("foo", "bar", "baz"); // const result: "foo:bar:baz"
```

Check this code out on a [playground!](https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBDAnmYcDCEB2BjAhjAHgBU5QZhMATAZzmpigEtMBzAbQF0A+OAXjhJkKNOGwBQcSXGYAzYFDgAxUiHJVa9JqwA0EqQDpDs+XABKKtSM3N2HPRzgB+OAAMAJAG9FAX08Yc+ASmXN4ucABccADkUQDcYmIyAK44MIxYcABWEMzEFsIaDDacXAAUhvrWrNSRRACUkf54hEQ8HnqSUMAwSVCYdEXV+tnMpQBEY3VwuLRNga3x3gnYWPRwCvwjmOMyEBBj2nBjAEa4UAdHpwBek7FwAPT3cCuYa1CRY7sQp1DXYwmPdBYZpwADujBgAAtpnRgGAzvhoOEEqBILAEMhUAAFLooKgASRkABFgDJmMBKHkhOoBloWIcAMr5GlVFg8fiCVQFI5jJwCCKuTwM3weIiheJiVHQeBIFBAgIwBlUrkswa2RnMqxq9n86kicRSaSYOQKZR6wp03SGirGBTmc204p2SQOZzuLwinFw4SEklkzAUghzRVBRlcMOhAUxCXJVLpfpbADqEMhDLhCJg0AITIdrLK1HTUER7zgDKm7UNXR6fTgsewaQyyssFqdZQqrJqAga8uaSqIYbgFcNUirvX6HeGOW2BfhRczUCmMx7+D7YfihqWSzELzeACY+Fkp8moWnZ8XxuFJjs9hcTmdb386ndATv4FBdx8vuEft-cDcgA)