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!

Top comments (6)

Collapse
 
maxim_mazurok profile image
Maxim Mazurok

The first approach seems to return any after a certain number of combinations. The second one appears to hang the process, I guess if you have the need and patience it could work for larger sets then the first option. Interesting stuff πŸ‘

Collapse
 
servernoj profile image
servernoj

Insanely big thank you for revealing the recursive approach in TS!!!

Collapse
 
jonrandy profile image
Jon Randy πŸŽ–οΈ • Edited

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

Collapse
 
activenode profile image
David Lorenz • Edited

This is normally something that people tend to say that do not use it / have not made huge experiences with it.

TypeScript is, for very good reasons, the de-facto standard in JS Web Development. It's not like big companies just choose it because they are bored. Years ago, Slack has revamped the whole JS-based App to make it work with TypeScript.

The benefits are massive but I think there's enough resources on the internet so I'll be refraining just posting the links now :).

Long story short: What's described in the article can be summarized with two good samples:

  1. (more related to what the author posted): I am creating a library that expects certain car manufacturer codes in the regex format: 'A{2,3}-[BC]+-[\d]+' . What's proposed in that article e.g. with the Concat utility type makes it possible for developers using your library to immediately see that AAAA-BCD-123 is invalid. With normal JS it would not show as an error.

  2. I am building a library for Maths operations in which some situations expects Square Matrices. With recursive Types in TS you can ensure that. With JS you cannot. You only see potential failure when executing it (which is bad DX)

So far my 2 cents from the Perspective of a TS Instructor.

I mean people do use JS - literally - in TS. They just make it even more safe, especially, but that's just one benefit, when working in teams. Super-convenient once you get used to it. Nobody that went to understanding TS that I know ever went back to plain JS.

Collapse
 
jonrandy profile image
Jon Randy πŸŽ–οΈ • Edited

Believe me I've used it and also have a lot of experience with strictly typed languages - they are how I started out in programming almost 40 years ago. When I first came to JS it was liberating... so much easier to be creative, faster. Coming to TS felt like a backwards step. It really just rubs me the wrong way, slows development, and makes things that should be simple, just - irritating.

I appreciate a lot of people like it, but I also know a lot of people and companies who don't, and also people who've moved back to JS. Each to their own - whatever works for you.

Thread Thread
 
activenode profile image
David Lorenz

I think it depends. I do see how, for specific things, you'd choose plain JS.

In big companies were there is a lot of people movement (leavers, newjoiners, etc.) we always found it extremely helpful to have TypeScript.

Personally I love coming back to older side projects that have TypeScript in it as they are like a helping hand when you haven't touched the code in a year.

But yeah, whatever works for one, let's agree on that!