Reputation: 2771
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:
RouteConfig
, does the change apply to all usages?RouteConfig
?Is there any way I can achieve all of the above?
Upvotes: 2
Views: 787
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
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' });
}
Upvotes: 1