DEV Community

ncpa0cpl
ncpa0cpl

Posted on

TypeScript string literal type concatenation

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:

const a = "foo"; // Inferred type: const a: "foo"
Enter fullscreen mode Exit fullscreen mode

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:

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"
Enter fullscreen mode Exit fullscreen mode

Solution

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

function join<T extends string[]>(...strings: T): Concat<T> {
  return strings.join("") as Concat<T>;
}
Enter fullscreen mode Exit fullscreen mode

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:

type R = Concat<["foo", "bar", "baz"]>; // type R = "foobarbaz"
Enter fullscreen mode Exit fullscreen mode

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

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;
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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

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

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"
Enter fullscreen mode Exit fullscreen mode

Check this code out on a playground!

Discussion (1)

Collapse
jonrandy profile image
Jon Randy • Edited on

Another solution - make your life easier and use plain JS. All of this hassle - for what? It baffles me why people choose TypeScript