Reputation: 6175
I have a little experiment in typescript, trying to set up a helper for laying out a route hierarchy.
const route = <TParams = {}, TChildren = {}>(path: string, children?: TChildren) => ({
path,
params: undefined as TParams,
children
});
I would use it like so:
const routes = {
LOGIN: route<MyParams>("/login", {
REGISTER: route("/register")
})
};
I would expect the type of routes
to end up like this:
{
LOGIN: {
path: string;
params: MyParams;
children: {
REGISTER: {
path: string;
params: {};
children: {};
}
}
}
}
But instead, I get this:
{
LOGIN: {
path: string;
params: MyParams;
children: {}
}
}
The type of REGISTER
is returned correctly by the route
function. E.g. I can assign route("/register")
to a variable and it will have the correct type, but if I try to nest it in children, then routes
just displays REGISTER
as any
instead.
If I get rid of the MyParams
type argument, then it becomes:
{
LOGIN: {
path: string;
params: {};
children: {
REGISTER: any;
}
}
}
Can anyone tell me what's going on here, and how to achieve my expected behavior?
I'm using Typescript 2.8.1.
Upvotes: 0
Views: 303
Reputation: 18292
It won't work, because by defining your type with default type parameters, when you don't give a second parameter, it assumes its default value, {}
. {REGISTER: route('/register')}
is assignable to {}
, so you don't get an error, but, of course, you lose the type information.
For it to work, you should let TypeScript infer what type is TChildren
. But, if you remove the default param, you'll get an error because type params without defaults should be first. If you change the order, you won't be able to give TParams
a value without giving one to TChildren
. So, the best way for you is to let TypeScript infer both params instead of calling it with them. For that, you'll need to use a little trick, and pass an object with that type so it can be inferred. It can be an empty object:
const route = <TParams, TChildren>(type: TParams, path: string, children?: TChildren) => ({
path,
params: undefined as TParams,
children,
});
And you can call it like:
const routes = {
LOGIN: route({} as MyParams, "/login", {
REGISTER: route({} as MyParams, "/register", null)
})
};
Now, as @jcalz said, the type for routes.LOGIN.children
is correctly inferred though it is not correctly displayed.
This solution is a bit more cumbersome, but works.
Upvotes: 1
Reputation: 328272
Part of your problem is that, even with default generic parameters, TypeScript currently has an all-or-nothing approach to generic parameter inference. If you specify some parameters and leave out others, those will get default values, not inferred ones.
This is where I usually suggest people use currying to split a function apart into two functions, one of which you explicitly set parameters on, and the other you rely on inference for. For example:
const route = <TParams>() => <TChildren>(path: string, children?: TChildren) => ({
path,
params: undefined! as TParams,
children
});
In this case, route
is a function that returns a function. This is how you call it:
const routes = {
LOGIN: route<MyParams>()("/login", {
REGISTER: route()("/register")
})
};
That extra call is a little weird, but let's inspect the type of routes
:
{
LOGIN: {
path: string;
params: MyParams;
children: {
REGISTER: any;
} | undefined;
};
}
It's great, right? Note that you get undefined
because children
is optional, and therefore possibly undefined
.
Oh, that any
is still there. Actually that just seems to be a type display bug where the IntelliSense gives up on processing types involving some amount of nested inference. The actual type is calculated properly:
const children = routes.LOGIN.children;
If you inspect children
it shows up as:
const children: {
REGISTER: {
path: string;
params: {};
children: {} | undefined;
};
} | undefined
which is the right type. The display bug is a shame, but if you know about it you can probably work around it.
Hope that helps; good luck!
Upvotes: 3
Reputation: 191749
This is currently not supported in TypeScript: https://github.com/Microsoft/TypeScript/issues/10571
You will have to specify both types. Currently if you specify one generic type it will use the type default rather than using inference -- that is to say you can't have it infer one generic type if you specify the other.
The only way around this is to specify both generic types or update the function to pass parameters as an argument so both types can be inferred.
const registerRoutes = {
REGISTER: route("/register"),
};
const routes = {
LOGIN: route<MyParams, typeof registerRoutes>("/login", registerRoutes)
};
Upvotes: 1