#847Hard

String Join

A curried join function whose return type is the exact joined string literal, computed from the delimiter and arguments by a recursive template literal type.

join('-')('a', 'b', 'c') should return the exact literal type 'a-b-c', not just string.

Most type challenges hand you a type alias to fill in. This one types a real function, and a curried one at that: the delimiter is captured by the outer call, the arguments are inferred as a tuple of string literals by the inner call, and a recursive template literal type folds them together. It's the same machinery that lets libraries compute exact route strings or class names at compile time.

The utility can be used like so:

const hyphenJoiner = join('-')
const result = hyphenJoiner('a', 'b', 'c'); // = 'a-b-c'

Or alternatively:

[object Object]

When we pass an empty delimiter (i.e '') to join, we should concat the strings as they are, i.e:

[object Object]

When only one item is passed, we should get back the original item (without any delimiter added):

[object Object]

Challenge Instructions: String Join

Hard

Create a type-safe string join utility which can be used like so:

const hyphenJoiner = join('-')
const result = hyphenJoiner('a', 'b', 'c'); // = 'a-b-c'

Or alternatively:

[object Object]

When we pass an empty delimiter (i.e '') to join, we should concat the strings as they are, i.e:

[object Object]

When only one item is passed, we should get back the original item (without any delimiter added):

[object Object]

View on GitHub: https://tsch.js.org/847

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

declare function join(delimiter: any): (...parts: any[]) => any

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

// Edge cases
const noCharsOutput = join('-')()
const oneCharOutput = join('-')('a')
const noDelimiterOutput = join('')('a', 'b', 'c')

// Regular cases
const hyphenOutput = join('-')('a', 'b', 'c')
const hashOutput = join('#')('a', 'b', 'c')
const twoCharOutput = join('-')('a', 'b')
const longOutput = join('-')('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h')

type cases = [
  Expect<Equal<typeof

Pro Challenge

Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.

Monthly subscription. Cancel anytime.

Detailed Explanation

The full solution:

type Join<Parts extends string[], D extends string> =
  Parts extends [infer First extends string, ...infer Rest extends string[]]
    ? Rest extends []
      ? First
      : `${First}${D}${Join<Rest, D>}`
    : ''
 
declare function join<D extends string>(
  delimiter: D
): <Parts extends string[]>(...parts: Parts) => Join<Parts, D>

There are two halves: a function signature that captures literal types, and a recursive Join type that computes with them. The signature first.

Capturing the delimiter with an outer generic

join<D extends string>(delimiter: D) is the outer half of the curried API. Because D is a type parameter constrained to string (not annotated as plain string), TypeScript infers the literal type of the argument: calling join('-') gives D = '-', not D = string. That literal is then baked into the returned function's type. It's a closure at the type level, mirroring the closure you'd write at runtime.

Inferring the arguments as a literal tuple

The inner function is generic too: <Parts extends string[]>(...parts: Parts). Two inference behaviors combine here:

Without the generic, say (...parts: string[]), you'd get a widened string[] and the exact result type would be unrecoverable. Declaring Parts on the inner function (rather than alongside D) is also what makes the curried style work: it's inferred fresh on every call of hyphenJoiner.

Folding the tuple: the Join type

Join<Parts, D> walks the tuple recursively. The pattern

[object Object]

splits a tuple into its head and tail, with inline extends constraints on the infers so First and Rest are immediately usable as string types (no extra conditional needed). Then:

Tracing Join<['a', 'b', 'c'], '-'>:

// 'a' + '-' + Join<['b', 'c'], '-'>
// 'a' + '-' + ('b' + '-' + Join<['c'], '-'>)
// 'a' + '-' + ('b' + '-' + 'c')   →  'a-b-c'

A delimiter is only ever emitted between two parts. That's the classic join invariant, encoded by putting the separator inside the recursive branch rather than after every element.

Edge cases the tests cover

The takeaway pattern: pair literal-preserving generics on a function signature with a recursive tuple-folding type, and the compiler can compute exact result strings for you. Once you've seen it here, you'll recognize it in typed path.join implementations and query builders.

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges