Search code examples
javascripttypescriptfunctional-programming

Is there a better way to infer the transformed record from a readonly record in typescript?


I have a record ROUTES which have can have route name and its properties. Every route must have path and title key and any additional property.

export type TRoute = {
  path: string;
  title: string;
} & Record<string, any>;

export type TRoutes = Record<string, TRoute>;

export const ROUTES = {
  dashboard: {
    path: "/dashboard",
    title: "Dashboard"
  },
  todos: {
    path: "/todos",
    title: "Todos"
  },
  settings: {
    path: "/settings",
    title: "Settings",
    foo: 2
  }
} as const satisfies TRoutes;

I also have made a functions to transform ROUTES record :-

getRoutesList - returns array of route properties

type TValueOfRecord<T> = T[keyof T];
type TRecordOfRecord<T> = Record<string, TValueOfRecord<T>>;

const getRoutesList = <T extends TRecordOfRecord<T> & TRoutes>(routes: T) => {
  return Object.values(routes);
};

export const ROUTE_LIST = getRoutesList(ROUTES); // ROUTE_LIST is inferred as something like ([ {path: "/dashboard", title: "Dashboard" }, {path: "/todos", title: "Todos"}, {path: "/settings", title: "Settings", foo: 2}] & {...;} & Record<...>)[]

Now if I map over ROUTE_LIST typescript doesn't complain about foo not exist. Why is it so and how should I refactor the code for simplicity ?

ROUTE_LIST.map((route) => {
  console.log(route.foo); // <- doesn't complain
  return route;
});


Solution

  • It's quite hard to give advice when the functions in question do not have an explicit return type, so it is partly a guess what you are after (as well as it is a practice I would discourage: TS might be happy to infer a type, that does not mean TS is necessarily inferring the intended type.)

    That said, I find immediately few problems with your code:

    1. type intersection does not work as you seem to expect, what you get is only what is common to both operands;

    2. the satisfies operator "lets us validate that the type of an expression matches some type, without changing the resulting type of that expression", my emphasis;

    3. last but not least, when you declare a term to have type any, that disables type-checking altogether, on that term and on every expression in which that term appears, which is the reason why eventually your code cannot work at all ("doesn't complain"). When you have no information and cannot assume anything about the type of a term, the type to use should be unknown.

    // usable *only if* `string` extends `V`!
    export type TRoute = {
        path: string;
        title: string;
    } & Record<string, any>;  // say "Record<string, V>"
    
    // no error *only if* `string` extends `V`!
    const settings: TRoute = {  
        path: "/settings",
        title: "Settings",
        foo: 1                  // must satisfy `V`
    };
    
    settings.path;        // (property) path: string
    settings.title;       // (property) title: string
    settings["foo"];      // any  (i.e. whatever `V` is)
    settings["anything"]; // any  (i.e. whatever `V` is)
    settings.anything;    // any  (i.e. whatever `V` is)
    
    export type TRoutes = Record<string, TRoute>;
    
    export const ROUTES = {
        dashboard: {
            path: "/dashboard",
            title: "Dashboard"
        },
        todos: {
            path: "/todos",
            title: "Todos"
        },
        settings: {
            path: "/settings",
            title: "Settings",
            foo: 2
        }
    } as const satisfies TRoutes;  // but "satisfies" is not the same as "is"!
    
    ROUTES;               // const ROUTES: {...}        (not a `TRoutes`)
    ROUTES.settings;      // (property) settings: {...} (not a `TRoute`)
    ROUTES.settings.foo;  // 2                          (not an `any`)
    

    Link to playground