You want something like partial type parameter inference, which is not currently a feature of TypeScript (see microsoft/TypeScript#26242). Right now you either have to specify all type parameters manually or let the compiler infer all type parameters; there’s no partial inference. As you’ve noticed, generic type parameter defaults do not scratch this itch; a default turns off inference.
So there are workarounds. The ones that work consistently but are also somewhat annoying to use are either currying or “dummying”. Currying here means you split the single multi-type-argument function into multiple single-type-argument functions:
type Obj = Record<string, any>; // save keystrokes later
declare const createTaskCurry:
<I extends Obj = {}>() => <O extends Obj>(t: TaskFunction<I, O>) => Task<I, O>;
createTaskCurry()(a => ({ foo: "" }));
// I is {}, O is {foo: string}
createTaskCurry<{ bar: number }>()(a => ({ foo: "" }));
// I is {bar: number}, O is {foo: string}
createTaskCurry<{ bar: number }>()<{ foo: string, baz?: number }>(a => ({ foo: "" }));
// I is {bar: number}, O is {foo: string, baz?: number}
You have the exact behavior you want with respect to your I
and O
types, but there’s this annoying deferred function call in the way.
Dummying here means that you give the function a dummy parameter of the types you’d like to manually specify, and let inference take the place of manual specification:
declare const createTaskDummy:
<O extends Obj, I extends Obj = {}>(t: TaskFunction<I, O & {}>,
i?: I, o?: O) => Task<I, O>;
createTaskDummy(a => ({ foo: "" }));
// I is {}, O is {foo: string}
createTaskDummy(a => ({ foo: "" }), null! as { bar: number });
// I is {bar: number}, O is {foo: string}
createTaskDummy(a => ({ foo: "" }), null! as { bar: number },
null! as { foo: string, baz?: number });
// I is {bar: number}, O is {foo: string, baz?: number}
Again, you have the behavior you want, but you are passing in nonsense/dummy values to the function.
Of course, if you already have parameters of the right types, you shouldn’t need to add a “dummy” parameter. In your case, you certainly can provide enough information in the task
parameter for the compiler to infer I
and O
, by annotating or otherwise specifying the types inside your task
parameter:
declare const createTaskAnnotate:
<O extends Obj, I extends Obj = {}>(t: TaskFunction<I, O>) => Task<I, O>;
createTaskAnnotate(a => ({ foo: "" }));
// I is {}, O is {foo: string}
createTaskAnnotate((a: { bar: number }) => ({ foo: "" }));
// I is {bar: number}, O is {foo: string}
createTaskAnnotate((a: { bar: number }): { foo: string, baz?: number } => ({ foo: "" }));
// I is {bar: number}, O is {foo: string, baz?: number}
This is probably the solution I’d recommend here, and is in effect the same as the other answer posted. So all this answer is doing is painstakingly explaining why what you want to do isn’t currently possible and why the available workarounds lead you away from it. Oh well!
Okay, hope that helps make sense of the situation. Good luck!
Playground link to code