javascripttypescripttypescript-generics

Type-safe function to merge path segments based on an array of keys


The following Paths type represents a number of url paths broken up by path segment.

type PathSegment = {
    path: string;
    children: Paths;
}

type Paths = Record<string, PathSegment | string>;

The following is an example object matching this type:

const paths: Paths = {
    home: "/",
    profile: "/profile",
    settings: {
        path: "/settings",
        children: {
            general: "/general",
            account: "/account",
        }
    }
}

Is it possible to create a function that merges path segments together in a type-safe way by providing the keys of the paths that should be merged? For example, given the paths object above, I could do something like:

const accountSettingsPath = mergePathSegments(paths, ["settings", "account"]);
console.log(accountSettingsPath); // outputs "/settings/account"

The keys passed in via the array should be known at compile-time so that a typo can't be made. And the array can't be bigger than the number of keys that are available. Is something like this possible?


Solution

  • You can build types for both input and output path using recursion:

    Playground

    type PathSegment = {
        path: string;
        children: Paths;
    }
    
    type Paths = Record<string, PathSegment | string>;
    
    const paths = {
        home: "/",
        profile: "/profile",
        settings: {
            path: "/settings",
            children: {
                general: "/general",
                account: "/account",
            }
        }
    } as const satisfies Paths;
    
    type Breadcrumbs<T extends Paths> = 
      {[K in keyof T]: T[K] extends string ? 
        [K] : 
        T[K] extends PathSegment ? 
          [K, ... Breadcrumbs<T[K]['children']>] : 
          never 
      }[keyof T];
    
    type MakePath<T extends Paths, B extends Breadcrumbs<T>> = 
      {[K in B[0]]: T[K] extends string ? 
        T[K] : 
        T[K] extends PathSegment ?  
          `${T[K]['path']}${B extends [string, ...infer C] ? C extends Breadcrumbs<T[K]['children']> ? MakePath<T[K]['children'], C> : '' : ''}` : 
          never 
      }[B[0]];
    
    declare function mergePathSegments<T extends Paths, const B extends Breadcrumbs<T>>(paths: T, breadcrumbs: B): MakePath<T, B> extends `${infer A}` ? A : string
    
    const accountSettingsPath = mergePathSegments(paths, ["settings", "account"]); // "/settings/account"
    console.log(accountSettingsPath); // outputs "/settings/account"