Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: infer type of generic after optional first generic

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?

like image 482
woubuc Avatar asked Feb 24 '20 13:02

woubuc


People also ask

How do you define a generic type in TypeScript?

Generics allow creating 'type variables' which can be used to create classes, functions & type aliases that don't need to explicitly define the types that they use. Generics makes it easier to write reusable code.

What is infer generic type arguments?

Type inference represents the Java compiler's ability to look at a method invocation and its corresponding declaration to check and determine the type argument(s). The inference algorithm checks the types of the arguments and, if available, assigned type is returned.

How do I make my generic optional TypeScript?

To make a generic type optional, you have to assign the void as the default value. In the example below, even though the function takes a generic type T, still you can call this function without passing the generic type and it takes void as default.

Can you declare a variable with a generic type?

Yes it is possible, but only for Functions, not any arbitrary variable. As you can see, it's the type itself, where you define generics and then you can make a variable of that type, which allows it to set the generic.


2 Answers

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

like image 178
jcalz Avatar answered Sep 22 '22 02:09

jcalz


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;}>
like image 28
Rob Napier Avatar answered Sep 18 '22 02:09

Rob Napier