Reputation: 46422
I need to declare an object having arbitrary string keys and values of type number
or string
, i.e., something like in this question.
So I wrote
interface AnyObjectWithNumberAndStringValues {
[s: string]: number | string;
}
and a test function
function f(x: AnyObjectWithNumberAndStringValues) {
console.log(x);
}
and it sort of work. I can call
f({ id: 123, name: "noname" })
But now, I have an interface and an object like
interface ISupplier {
id: number;
name: string;
}
const o: ISupplier = { id: 123, name: "noname" };
and calling f(o)
produces the following error:
Argument of type 'ISupplier' is not assignable to parameter of type 'AnyObjectWithNumberAndStringValues'. Index signature is missing in type 'ISupplier'.ts(2345)
More precisely: How can I declare that f
accepts any object as long as its values are only numbers and strings?
See this sandbox for a full example. Note that it seems to be non-deterministic: The error may or may not appear!
Upvotes: 2
Views: 630
Reputation: 328272
It is definitely confusing to figure out what sorts of types are assignable to and from an indexable type. The reason why
f({ id: 123, name: "noname" }); // okay
works is because the argument is an object literal, which is given an implicit index signature. At the time this occurs, the compiler knows that the parameter is an object literal and therefore it doesn't have any unknown properties on it. Thus it is safe to treat it as something assignable to AnyObjectWithNumberAndStringValues
.
The reason why
const o: ISupplier = { id: 123, name: "noname" };
f(o); // error
doesn't work is because o
is given a type annotation of ISupplier
. By the time f(o)
is called, the compiler has forgotten about the specific properties inside o
. For all the compiler knows there may be unknown non-string
and non-number
properties in there.
Consider the following code:
interface ISupriser extends ISupplier {
surprise: boolean;
}
const s: ISupriser = { id: 123, name: "noname", surprise: true };
const uhOh: ISupplier = s;
f(uhOh); // error makes sense now
An ISurpriser
contains a boolean
property, so you wouldn't want to call f()
with it. But every ISupriser
is also an ISupplier
. The variable uhOh
is just as valid of an ISupplier
as your o
was. And all the compiler remembers about both o
and uhOh
is that they are instances of ISupplier
. That's why f(o)
fails... it's to prevent you from accidentally calling f(uhOh)
.
Note that any workaround you do which allows you to call f()
or a similar function on something that isn't an object literal has the potential to allow unexpected arguments, with bad properties the compiler doesn't know about (or doesn't remember). But let's look at these workarounds anyway:
It has been noted that one workaround is to use a type alias for ISupplier
instead of an interface. Apparently the trade-off here is that it's not as easy to extend type aliases (you can get the same effect with intersections) so it's "safe enough" to consider it as having an implicit index signature:
type TSupplier = {
id: number;
name: string;
};
const p: TSupplier = { id: 123, name: "noname" };
f(p); // okay
It might not be "as easy" to add undesirable properties as it is with an interface, but it's still easy enough:
const ohNo: TSupplier = uhOh; // okay
f(ohNo); // no error, but there probably should be one!
Another workaround, especially if you can't change ISupplier
, is to use a constrained generic type in your function instead of an index signature, like this:
function g<T extends Record<keyof T, number | string>>(x: T) {
console.log(x);
}
The g()
function basically only accepts arguments whose known properties are assignable to number | string
:
g({ id: 123, name: "noname" }); // okay
g(o); // okay
g(s); // error, "surprise" is incompatible
Again, though, if the compiler doesn't know about a property, it can't check it, so these also have no error:
g(uhOh); // no error, but there probably should be one
g(ohNo); // no error, but there probably should be one
Anyway, hope that helps you. Good luck!
Upvotes: 1