41

I have a function with two generic types, In and Out:

function createTask<
  In extends Record<string, any> = {},
  Out extends Record<string, any>,
>(task : TaskFunction<In, Out>) : Task<In, Out>

type TaskFunction<In, Out> = (args : TaskWrapper<In>) => Out | Promise<Out>; 
// TaskWrapper wraps several other types and interfaces, so args is more than just `In`

This code currently does not compile, because you cannot have a required generic type (Out) after an optional one (In).

How do I tell the Typescript compiler that I want to let the user of this function do one of three things:

  1. Don't specify any generics: createTask(...). The type of In should default to {}, and Out should be inferred from the return value of the TaskFunction.

  2. Specify only In: createTask<A>(...). As above, Out should be inferred.

  3. Specify both In and Out: createTask<A, B>(...).

Essentially I'm looking for a way to say "this generic is optional and should be inferred". I know there's an infer keyword but from the limited documentation I've found on it, it doesn't seem to support this use case.

I've also tried to assign a default value to Out, but then it always uses that default value instead of inferring from TaskFunction.

I can reverse the order of In and Out, but then Out always has to be specified even though it can easily be inferred, if the user wants to specify In.

I also prefer not to force users to add the default value {} every single time they call the function.

Is this at all possible to do with Typescript, or will I have to always require In to be specified?

5
  • Have you tried adding default to Out as well? Out extends Record<string, any> = Record<string, any> Commented Feb 24, 2020 at 14:07
  • @AlekseyL. I mentioned this, if I add a default to Out it always uses the default instead of inferring. I updated my post to make this clearer. Commented Feb 24, 2020 at 14:09
  • 1
    Here Out resolved as expected.. Commented Feb 24, 2020 at 14:13
  • @AlekseyL. That's because it can infer from the standalone function declaration. It doesn't work when used like this, which is the intended usage. Commented Feb 24, 2020 at 14:23
  • OK, that's not related to passed function, but once you provide single generic parameter the other one is resolved to default... Commented Feb 24, 2020 at 14:45

2 Answers 2

47

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

Sign up to request clarification or add additional context in comments.

2 Comments

So it's not possible in Typescript (or at least not right now), that's a pretty definitive answer to my question. Thanks for the detailed explanation! Looks like I'll have to change my API design a little bit..
Well, the currying does work pretty nicely for splitting type parameters to fixed and infered parts: const someFunc = <TFixed>() => <TInfered>(arg: TInfered) => ..., you just need to add an extra () between the actual call and the fixed type parameters when you call someFunc<SomeFixedType>()(someArgument).
3

First, take out the default type entirely:

declare function createTask<
    In extends Record<string, any>,
    Out extends Record<string, any>,
    >(task: TaskFunction<In, Out>): Task<In, Out>;

For the case you're describing, where In is passed:

const r1 = createTask<{ a : number }>(arg => {
    return { b: arg.a };
}); // Error: Expected 2 type arguments, but got 1.

Don't pass it as a type-parameter. Annotate the value you want to constrain and let it infer the rest of the types:

const r1 = createTask((arg: { a: number }) => {
    return { b: arg.a };
}); // r1 is Task<{a: number;}, {b: number;}>

This also works when all the types are known:

declare function foo(arg: { a: number }): { b: boolean };

const r1 = createTask(foo); // r1 is Task<{a: number;}, { b: boolean;}>

I tried adding TaskWrapper as you indicated in your edits. The solution seems identical.

type Task<In, Out> = { a: In, b: Out}
type TaskWrapper<T> = { a: T }
type TaskFunction<In, Out> = (args : TaskWrapper<In>) => Out | Promise<Out>; 

declare function createTask<
  In extends Record<string, any>,
  Out extends Record<string, any>,
>(task : TaskFunction<In, Out>) : Task<In, Out>

const r1 = createTask((args: TaskWrapper<{ a: number }>) => {
    return { b: args.a.a };
}); // r1 is Task<{a: number;}, {b: number;}>

3 Comments

Unfortunately this isn't really possible, since the args are more than just the type of In. So I'd really like createTask to provide the types. I've made this clearer in my post.
I don't understand how In wrapping more types changes anything here. Can you give an example of what's giving you trouble? I tried adding TaskWrapper, and it just changed the above code from (arg: { a: number}) to (args: TaskWrapper<{ a: number}>). Moving the type from the <> to the () still seems to work identically.
My intention was to abstract away the types that are unnecessary for the user of the API. So the user would just have to define their own specific types, and everything else would be taken care of by my library. But it seems what I was hoping for isn't possible in Typescript, as jcalz said in their answer. So I will indeed have to use a different approach, like this one.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.