I'm writing a widget utility class in Typescript. It is basically a hierarchy of widgets, and then some operations can be performed on that hierarchy, something along the lines of
type WidgetBase = Widget<any, any>;
type SubWidgetsBase = {[K: string]: WidgetBase};
type Widget<LoaderResult, SubWidgets extends SubWidgetsBase> = {
subWidgets: SubWidgets;
loader: (args: { subWidgets: SubWidgets }) => LoaderResult;
};
There's a function to infer all the types, and it seems to work in the basic scenario:
function mkWidget<LoaderResult, SubWidgets extends SubWidgetsBase>(args: {
subWidgets: SubWidgets;
loader: (args: { subWidgets: SubWidgets }) => LoaderResult;
}): Widget<LoaderResult, SubWidgets> {
return args;
}
const w1 = mkWidget({
subWidgets: {},
loader: ((args) => ["w1", args.subWidgets] as const)});
// ^? (property) subWidgets: {}
const w2 = mkWidget({
subWidgets: { w1 },
loader: ((args) => ["w2", args.subWidgets] as const)
// ^? (property) subWidgets: { w1: Widget<readonl…
});
But it is a fullstack framework, and I need to make sure some of the code doesn't make it to the frontend. There's a macro for that expands to undefined
in client bundle during transpiling, so it looks something like this:
// wrapLoader(foo) is a macro that expands verbatim into "foo" on the server
// and "undefined" on the client. To make Typescript happy it is defined as
function wrapLoader<T>(t: T): T | undefined { return t; }
const w3 = mkWidget({
subWidgets: { w1 },
loader: wrapLoader((args) => ["w3", args.subWidgets] as const)!
// ^? (property) subWidgets: never
});
And although Typescript is able to deduce the loader arguments on mkWidget
call itself, it fails to propagate the inferred types to the inline function through the wrapper:
Type
'(args: { subWidgets: never; }) => ["w3", never]'
is not assignable to type
'(args: { subWidgets: { w1: Widget<readonly ["w1", {}], {}>; }; }) => unknown'
Things that didn't work:
<T>(t: T): T => t
!
modifierNoInfer<SubWidgets>
to loader
's type signatureThings that worked, but look a bit clumsy:
mkWidget({ subWidgets })({ loader })
mkWidget(subWidgets, { loader })
(inspired by this)Here's the entire playground to play with.
Is there a problem with my code? If not, is it useful to expect this to be fixed in Typescript itself and submit it as a bug or is it expected to stay this way?
You're trying to get TypeScript to infer both generic type arguments and the contextual type of function parameters, at the same time. (In your example, you need LoaderResult
, SubWidgets
, T
, and args
to be inferred.) In general, TypeScript cannot do this consistently. It's a design limitation or missing feature that will probably persist forever in some form; see microsoft/TypeScript#47599.
TypeScript's inference algorithm is heuristic in nature, and performs several "passes", to infer generic type arguments, and then contextual types. A function like (args) => ["w3", args.subWidgets] as const
, where args
is not annotated, tends to have its inference deferred until after generic type arguments are inferred. If your types depend on each other in a way that TypeScript doesn't anticipate, then it can end up giving up, even though theoretically there's enough information for the inference to succeed. Indeed, there is such a thing as a "full unification algorithm" which can, in principle, always find appropriate types if they exist. But TypeScript does not use such an algorithm; see microsoft/TypeScript#30314 for a discussion about this.
The current inference algorithm tends to work best if it has to infer types "from left to right", such as when types for earlier function arguments can be used to infer types for later function arguments... as opposed to vice versa. This is why your positional argument approach succeeds. If you need types to be inferred in the other direction, there's a good chance it will fail. If you need multiple types to be inferred from a single function argument of an object type, it used to always fail. Now, things have been improved with microsoft/TypeScript#48538, and now types can sometimes be inferred "from left to right" inside an object or array literal. Indeed, your single parameter argument approach also succeeds, for this reason.
But you've gone further, and wrapped your argument in a generic function call where further generic inference has to take place first, where T
needs to be inferred from its contextual return type, which itself is in the process of being inferred. And it fails. It's possible that some future version of TypeScript will support this particular scenario, but there will always be situations it cannot support.
In these situations you should either refactor so that you don't need to infer multiple things at once (e.g., your currying approach to split into multiple steps), or be prepared to start manually specifying your generic type arguments and/or manually annotating your function parameters.