2

I have a generic type called RouteConfig which I am trying to use inside of an object called routes.

// Generic type
type RouteConfig<Params> = {
    path: string;
    component: (params: Params) => string;
};

type Route = 'Home' | 'User';

// Object
const routes: Record<Route, RouteConfig<unknown>> = {
    Home: {
        path: 'a',
        // False positive (should not error)
        component: (params: { foo: string }) => 'x',
    },
    User: {
        path: 'a',
        // False positive (should not error)
        component: (params: { bar: string }) => 'z',
    },
};

Each value inside of the object is allowed to have its own type for the Params generic inside of RouteConfig.

My problem is this: inside of the routes type annotation, what should I pass as the generic to RouteConfig?

I can't provide a single type since each object value is allowed to have its own type. (A union would apply the same union type to all object values, which is not what I want.)

In the example above I am using unknown, however this results in false positive type errors (see comments in code example above).

I can't use any because then I lose type safety when it comes to reading from the object:

const routes: Record<Route, RouteConfig<any>> = {
    Home: {
        path: 'a',
        // True negative
        component: (params: { foo: string }) => 'x',
    },
    User: {
        path: 'a',
        // True negative
        component: (params: { bar: string }) => 'z',
    },
};

// True negative
routes.Home.component({ foo: 'abc' });

// False negative (should error)
routes.Home.component({ bar: 'abc' });

I could drop the type annotation from routes:

const routes = {
    Home: {
        path: 'a',
        // True negative
        component: (params: { foo: string }) => 'x',
    },
    User: {
        path: 'a',
        // True negative
        component: (params: { bar: string }) => 'z',
    },
};

// True negative
routes.Home.component({ foo: 'abc' });

// True positive
routes.Home.component({ bar: 'abc' });

… but then I lose facilities like "find references" and "rename" for the type and properties inside of RouteConfig. Furthermore, I would lose some type safety because TypeScript would no longer be able to check that the object contains all of the required keys (defined by the Route type).

I think what I'm looking for is a way to annotate the type for the routes object except for the generic—the generic should be inferred from the object definition.

To sum up, I am looking for a way to write this that achieves all of the following:

  • Good dev UX:
    • if I rename a property in RouteConfig, does the change apply to all usages?
    • can I find all references for a property in RouteConfig?
  • No unexpected errors
  • Type safety (errors when expected)

Is there any way I can achieve all of the above?

TS playground

2 Answers 2

1

It's not exactly straight forward to get all those 3 elements at the same time. The one way I to it to work is to use an extra function for inference (to capture the actual type of the object literal) and then do some reworking of the input and output types. WIth the input I added & Record<Route, RouteConfig<any>> to the parameter type to let ts know the input values are of type RouteConfig<any> (otherwise ts would miss these during a rename) and the output type I passed through essentially an identity type that makes sure the refence to RouteConfig is preserved in the output (without this usage sites would be missed in a rename):


type RouteConfig<Params> = {
    path: string;
    component: (params: Params) => string;
};

type Route = 'Home' | 'User';
type RouteHelper<T extends Record<Route, RouteConfig<any>>> = {
    [P in keyof T] : RouteConfig<T[P] extends RouteConfig<infer P> ? P : never>
}
function createRoutes<T extends Record<Route, RouteConfig<any>>>(r: T & Record<Route, RouteConfig<any>>):  RouteHelper<T>{
    return r as any;
}


// [✅] Good dev UX
// [✅] No unexpected errors
// [✅] Type safety (errors when expected)

{
    const routes = createRoutes({
        Home: {
            path: 'a',
            // True negative
            component: (params: { foo: string }) => 'x',
        },
        User: {
            path: 'a',
            // True negative
            component: (params: { bar: string }) => 'z',
        },
    });

    // True negative
    routes.Home.component({ foo: 'abc' });

    // True positive
    routes.Home.component({ bar: 'abc' });
}

Playground Link

Sign up to request clarification or add additional context in comments.

7 Comments

Thank you so much! I had tried something like that but I defined the createRoutes parameter as just T. It seems we need to use an intersection. Do you know why those dev UX features can't work if we don't specify the intersection, out of curiosity? I wonder if there's a good reason or whether it's just a limitation/bug.
Also do you know if TS has a missing feature that would make this easier?
@OliverJosephAsh Although T extends X means T must be a subtype of X, this relation is only checked, after T is inferred at call site there is no direct relation between the actual T and the constraint X. This means when searching for references of RouteConfig, T will not in any way be on the radar. Sure you will get errors after a rename, when T is checked against X. This is why I put the intersection in the parameter, to give an extra hint about, the fact that the object literal passed in is not just a random inferred type T it is also Record<Route, RouteConfig<any>
@OliverJosephAsh This also applies to the output, without the helper type type T wold end up just being an object type and its properties would not be of type RouteConfig, they would just be the full object type inferred for each property, so again when renaming or searching for references this inferred type would not be in the radar. I suspect keeping the constraint in the type might be possible, but am unsure of the perf implications as it might not allow type reuse in certain cases.
@OliverJosephAsh besides the two aforementioned issues, I think the biggest gap missing feature in making this easier would be to allow variables to be partially inferred. This would remove the need for a function. Maybe a special type assertion something like let r = { ... } as <T extends Record<Route, RouteConfig<any>>
|
1

First we want to specify that Home should have parameters {foo: String} and User should have {bar: string}, so we need a mapping from those routes to their parameter types. We can use an object type for this:

type ParamTypes = {
    Home: {foo: string},
    User: {bar: string}
};

Now what we want to express is that for each routes, routes should have a member for that route of RouteConfig[T] where T is the type of ParamTypes.TheRoute. We can express this using index types like this:

{ [K in Route]: RouteConfig<ParamTypes[K]> }

So the definition of routes becomes

const routes: { [K in Route]: RouteConfig<ParamTypes[K]> } = {
    Home: {
        path: 'a',
        // False positive (should not error)
        component: (params: { foo: string }) => 'x',
    },
    User: {
        path: 'a',
        // False positive (should not error)
        component: (params: { bar: string }) => 'z',
    },
};

Now changing the parameter types of either component will cause an error, no casts are necessary and no type safety is sacrificed.

1 Comment

I accepted another answer but this is also a very good solution, thank you.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.