Oliver Joseph Ash
Oliver Joseph Ash

Reputation: 2771

TypeScript: inferring generic for type inside of annotated object

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:

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

TS playground

Upvotes: 2

Views: 787

Answers (2)

sepp2k
sepp2k

Reputation: 370445

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.

Upvotes: 1

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250366

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

Upvotes: 1

Related Questions