Reputation: 643
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?
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
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
Upvotes: 3
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