Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript generic inference from interface implementation

I'm trying to infer the return type of a method from the generic of an argument passed in. However, the argument is an implementation from a generic interface, so I would assume typescript inferencing would have determined the type from the argument's base.

Example code:

interface ICommand<T> {}

class GetSomethingByIdCommand implements ICommand<string> {
  constructor(public readonly id: string) {}
}

class CommandBus implements ICommandBus {
  execute<T>(command: ICommand<T>): T {
    return null as any // ignore this, this will be called through an interface eitherway
  }
}

const bus = new CommandBus()
// badResult is {}
let badResult = bus.execute(new GetSomethingByIdCommand('1'))

// goodResult is string
let goodResult = bus.execute<string>(new GetSomethingByIdCommand('1'))

What I'd like to do is the first execute call and have typescript infer the correct return value, which is string in this case based on what GetSomethingByIdCommand was implemented from.

I've tried playing around with conditional types but not sure if this is a solution or how to apply it.

like image 219
Shawn Mclean Avatar asked Mar 21 '19 21:03

Shawn Mclean


3 Answers

Your problem is that ICommand<T> is not structurally dependent on T (as mentioned in @CRice's comment).

This is not recommended. (⬅ link to a TypeScript FAQ entry detailing a case that is almost exactly the same as this one, so that's as close to an official word as we're likely to get here)

TypeScript's type system is (mostly) structural, not nominal: two types are the same if and only if they are the same shape (e.g., have the same properties) and it has nothing to do with whether they have the same name. If ICommand<T> isn't structurally dependent on T, and none of its properties have anything to do with T, then ICommand<string> is the same type as ICommand<number>, which is the same type as ICommand<ICommand<boolean>>, which is the same type as ICommand<{}>. Yes, those are all different names, but the type system isn't nominal, so that doesn't count for much.

You can't rely on type inference to work in such cases. When you call execute() the compiler tries to infer a type for T in ICommand<T>, but there's nothing from which it can infer. So it ends up defaulting to the empty type {}.

The fix for this is to make ICommand<T> structurally dependent in some way on T, and to make sure any type that implements ICommand<Something> does so correctly. One way to do this given your example code is this:

interface ICommand<T> { 
  id: T;
}

So an ICommand<T> must have an id property of type T. Luckily the GetSomethingByIdCommand actually does have an id property of type string, as required by implements ICommand<string>, so that compiles fine.

And, importantly, the inference you want does indeed happen:

// goodResult is inferred as string even without manually specifying T
let goodResult = bus.execute(new GetSomethingByIdCommand('1'))

Okay, hope that helps; good luck!

like image 177
jcalz Avatar answered Nov 11 '22 05:11

jcalz


Typescript seems to be able to correctly infer the type if the concrete type is coerced into its generic equivalent before it is passed to ICommandBus.execute():

let command: ICommand<string> = new GetSomethingByIdCommand('1')
let badResult = bus.execute(command)

Or:

let badResult = bus.execute(new GetSomethingByIdCommand('1') as ICommand<string>)

This isn't exactly an elegant solution, but it works. Clearly typescript generics are not very feature complete.

like image 32
Jake Holzinger Avatar answered Nov 11 '22 05:11

Jake Holzinger


TS cannot infer the interface that method is implementing in the way you want it to.

What is happening here is that when you instantiate a new class with:

new GetSomethingByIdCommand('1') 

The result of instantiating a new class is an object. For that reason execute<T> will return an object instead of the string that you expect.

You will need to do the type check after execute function returns a result.

In the case of object vs string, you can just do a typeof check.

const bus = new CommandBus()
const busResult = bus.execute(new GetSomethingByIdCommand('1'));
if(typeof busResult === 'string') { 
    ....
}

This works fine at runtime, when typescript is compiled to just plain JS.

In case of objects or arrays (which are also objects :D), you would use a type guard.

Type guard tries to cast an item to something and checks if a property exists and infers what model was used.

interface A {
  id: string;
  name: string;
}

interface B {
  id: string;
  value: number;
}

function isA(item: A | B): item is A {
  return (<A>item).name ? true : false;
}
like image 1
Vlatko Vlahek Avatar answered Nov 11 '22 05:11

Vlatko Vlahek