Karim Stekelenburg
Karim Stekelenburg

Reputation: 643

TypeScript Generics with interface parameters

I'm playing around with TypeScript generics and am a bit confused.

I'm basically trying to create an interface that has a method that can receive an arbitrary options parameter. This parameter can be any object. What the exact object looks like is determined by the implementing class.

interface MyOptions {
    foo: string
}

interface TestInterface {
    doSome<T extends Record<string, unknown>>(value: T): void
}

class TestClass implements TestInterface {
    doSome<T = MyOptions>(value: T): void {
        value.foo // complains that foo doesn't exist
    }
}

Everything looks fine, but when I try to access value.foo, it looks like value isn't typed.

Am I doing something wrong?

UPDATE

I found some useful stuff regarding interfaces not extending Record<string, unknown>, saying to use a type instead (see interface object couldn't extends Record<string, unknown>).

However, after updating the snippet above as shown below, the issue remains.

type MyOptions = {
    foo: string
}

interface TestInterface {
    doSome<T extends Record<string, unknown>>(value: T): void
}

class TestClass implements TestInterface {
    doSome<T = MyOptions>(value: T): void {
        value.foo // complains that foo doesn't exist
    }
}

Upvotes: 2

Views: 5109

Answers (2)

jcalz
jcalz

Reputation: 327624

There is an important difference between generic call signatures and generic types. A generic call signature has the generic type parameters on the call signature itself, like this:

interface TestInterfaceOrig {
    doSome<T extends object>(value: T): void
}

When you have a generic call signature, the caller gets to specify the type argument:

declare const testInterfaceOrig: TestInterfaceOrig;
testInterfaceOrig.doSome<{ a: string, b: number }>({ a: "", b: 0 });
testInterfaceOrig.doSome({ a: "", b: 0 });

In the above, the caller is able to choose that T is {a: string, b: number} in both calls. In the second call, the compiler infers that type from the value argument, but it's the same... the caller is in charge. The implementer, on the other hand, has to write doSome() so that it works for any T the caller chooses:

const testInterfaceOrig: TestInterfaceOrig = {
    doSome<T extends object>(value: T) {
        console.log(value); // 🤷‍♂️ there's not much I can do, 
        // all I know is that value is an object
    }
}

It doesn't help or change anything for the implementer to choose a type argument default, since defaults don't constrain the input in any way.

const testInterfaceOrig: TestInterfaceOrig = {
    doSome<T extends object = MyOptions>(value: T) {
        console.log(value); // 🤷‍♂️ still the same
    }
}

If the implementer tries to actually constrain the input, there is an error... the caller chooses, not the implementer:

const testInterfaceOrigNope: TestInterfaceOrig = {
    doSome<T extends MyOptions>(value: T) { // error!
        console.log(value.foo);
    }
}

This isn't what you're looking for, so you can't write it this way.


On the other hand, a generic type has the generic type parameters as part of the type declaration, like this:

interface TestInterface<T extends object> {
    doSome(value: T): void
}

Here, there is no TestInterface type by itself (although you can set defaults here too, but let's not digress). You can't have a value of type TestInterface. Instead, you can have a value of type TestInterface<{a: string, b: number}>, or TestInterface<MyOptions>, or TestInterface<T> for some object type T. And once you have a value of that type, the doSome() method can only accept values of type T. The call signature is not generic; the caller is unable to choose T here. The choice of T is made by whoever supplies the TestInterface<T> instance:

class TestClass implements TestInterface<MyOptions> {
    doSome(value: MyOptions): void {
        value.foo
    }
}

So here TestClass implements TestInterface<MyOptions>. The doSome() method only accepts MyOptions. That means the compiler knows that value has a foo property. And so the caller is not in charge of T at all, as desired:

const tc = new TestClass();
tc.doSome({ foo: "abc" }); // okay
tc.doSome({ a: "", b: 0 }); // error, that's not a MyOptions.
tc.doSome<{ a: string, b: number }>({ a: "", b: 0 }); // error, doSome is not generic

Playground link to code

Upvotes: 3

Clifford Bernard
Clifford Bernard

Reputation: 11

You cannot overload the function in that way. If you are trying to create a union for this, then you can use the following code example:

type MyOptions = Record<string, unknown> & { foo: string };

interface TestInterface {
  doSome<T extends MyOptions>(value: T): void;
}

class TestClass implements TestInterface {
  doSome<T extends MyOptions>(value: T): void {
    console.log(value.foo);
  }
}

const test = new TestClass();

test.doSome({ foo: "bar", baz: "qux" });

/*
*  The combined type of the two objects is:
* TestClass.doSome<{
    foo: string;
    baz: string;
}>(     value: {foo: string, baz: string}): void
* 
* */

Useful link for Dos & Don'ts for overloading link

UPDATE

I believe I understand what you are looking for; the issue you are running into is that Typescript does not know what properties of T are within your function or class. The compiler could not prove that the foo property is on every type. You have two options, either add a primitive type to the argument or apply a generic constraint through the extends keyword:

type MyOptions = {
  foo: string;
};

type MyOtherOptions = {
  bar: string;
};

interface TestInterface {
  doSome(value: unknown): void;
}

class TestClass<T extends Record<string, unknown>> implements TestInterface {
  doSome = (value: T): void => {
    console.log(value.foo);
  };
}

const test = new TestClass<MyOtherOptions>();
test.doSome({ bar: "baz" });

const test2 = new TestClass<MyOptions>();
test2.doSome({ foo: "bar" });

Upvotes: 1

Related Questions