Search code examples
typescripttemplate-literalsmapped-types

TypeScript template literal types


Last year Sam Stephenson (ex Basecamp/HEY) posted a very interesting tweet about some developments on the stimulus js library he was working on which unfortunately never the saw the light of day due to the recent basecamp fallout events.

Playing with TypeScript template literal types 🤯. Here’s an example showing how we could map a Stimulus controller’s static targets = [...] array to an interface with all of the generated properties:

I've been studying a bit of Typescript lately and was trying to decipher that specific tweet but it seems it's above my current level of understanding. I've already made an attempt in my previous post to understand the NamedProperty type here but I'm still very confused.

So, can anyone explain to me the following code?

type NamedProperty<Name extends string, T>
  = { [_ in 0 as Name]: T }

type UnionToIntersection<T>
  = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never
type ValueOf<T>
  = T[keyof T]

type TargetPropertyGroup<Name extends string>
  = NamedProperty<`has${Capitalize<Name>}Target`, boolean>
  & NamedProperty<`${Name}Target`, Element>
  & NamedProperty<`${Name}Targets`, Element[]>

// This type troubles me the most
type TargetProperties<Names extends string[]>
  = UnionToIntersection<ValueOf<{ [Name in Names[number]]: TargetPropertyGroup<Name> }>>

let controller!: TargetProperties<[ "form", "input", "result"]>

// Magical autocomplete happens here just after the dot!
let result = controller.

Thanks in advance!


Solution

  • Let's start with the pieces and then will try to get the whole thing. You already got what NamedProperty type does. It lets us define that an object has some property and the type of this property value.

    UnionToIntersection<T> turns a union into an intersection.

    type A = UnionToIntersection<{ a: number } | { b: string }>; 
    // infers as { a: number } & { b: string };
    

    You can find a great explanation of how it works in this question.

    ValueOf<T> takes a type and returns a union of types of values.

    type B = ValueOf<{ a: number; b: string }>;
    // infers as number | string;
    

    TargetPropertyGroup<Name extends string> takes a string and returns a type of object that contains three properties which names constructed based on the passed string and the value types are predefined. As you noticed, it uses template literals types.

    Now TargetProperties<Names extends string[]>.

    { [Name in Names[number]]: TargetPropertyGroup<Name> } iterates over all the values passed as a type argument and applies TargetPropertyGroup so that for each string passed to TargetProperties it generates a type that contains three properties.

    type C<Names extends string[]> = { [Name in Names[number]]: TargetPropertyGroup<Name> };
    let test = C<["form", "input", "result"]>;
    // infers as 
    // {
    //    form: TargetPropertyGroup<"form">;
    //    input: TargetPropertyGroup<"input">;
    //    result: TargetPropertyGroup<"result">;
    // }
    

    The last two steps are applying ValueOf<T> to this that, as e already know, returns types of values as a union: TargetPropertyGroup<"form"> | TargetPropertyGroup<"input"> | TargetPropertyGroup<"result"> and applying UnionToIntersection that turns the type into intersection. The resulting type looks like an object containing three fields per string passed to TargetProperties.