1

Hi everyone!

interface Thing {
  name: string;
}

interface ThingMap {
  [thingName: string]: Thing;
}

interface ThingMapClassA {
  first: { name: 'first thing name' };
  second: { name: 'second thing name' };
  third: { name: 'third thing name' };
}

interface ThingMapClassB {
  first: { name: 'first thing name' };
  second: { name: 'second thing name' };
  third: { name: 'third thing name' };
}

class Handler<T extends ThingMap> {}

const handler = new Handler<ThingMapClassA>();

I world like Handler to accept any class with properties (ideally at least one) of type Thing. But ThingMapClassA is not recognised. It leads to an error. Any suggestions?

Kleywalker
  • 49
  • 5
  • You're doing that thing again where you don't initialize your required class properties, causing [errors unrelated to your question](https://tsplay.dev/NVYZvw). Could you please use interfaces or initialize your properties? I'm sure neither of us wants to spend time discussing things that you're not asking about. – jcalz Apr 01 '23 at 13:42
  • Class instances and interfaces are not given implicit index signatures, as described in [ms/TS#15300](https://github.com/microsoft/TypeScript/issues/15300). Unless you want to require index signatures, you can make your constraint recursive as shown [in this playground link](https://tsplay.dev/WoYG9N). Does that fully address the question? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Apr 01 '23 at 13:45
  • Yes, this addresses my question. Thank you! – Kleywalker Apr 01 '23 at 14:11
  • Is there a way to nest it even further? Like having a Thing to have eny property of a Subthing interface? – Kleywalker Apr 01 '23 at 14:13
  • Is that a followup question? Comments aren't an appropriate place for those, unfortunately. You should create a new post (after searching for existing relevant questions and answers) if you need to, and include a [mre] there that demonstrates your issue (I'm not sure what you mean exactly by "nesting further"... and again, this is not the place for that discussion) – jcalz Apr 01 '23 at 14:16
  • like this: ```typescript interface Thing { name: string; } interface ThingMapClassA { first: { name: { name: 'first thing name' } }; } class Handler>> {} const handler = new Handler(); ``` – Kleywalker Apr 01 '23 at 14:18
  • I'm sorry, but this is not the right place for that. Please re-read my previous comment carefully. – jcalz Apr 01 '23 at 14:21
  • I'm sorry to couse any trouble. I will rewrite a new Post. – Kleywalker Apr 01 '23 at 14:30

1 Answers1

2

The type

interface ThingMap {
  [thingName: string]: Thing;
}

has a string index signature, meaning that if an object of that type has a property whose key is a string, the value of that property will be a Thing.

If you have an anonymous object type, such as the type inferred from an object literal, and try to assign it to a type with an index signature, the compiler will helpfully give it an implicit index signature:

const goodVal: ThingMap = { a: { name: "b" } }; // okay

const badVal: ThingMap = { a: "oops" }; // error
// ----------------------> ~
// Type 'string' is not assignable to type 'Thing'.
// The expected type comes from this index signature.

But implicit index signatures are not given to values of interface or class instance types. This is described at microsoft/TypeScript#15300. Observe:

interface Iface {
  a: Thing;
}
const iface: Iface = { a: { name: "b" } };
const alsoBad: ThingMap = iface; // error!
// Index signature for type 'string' is missing in type 'Test'.

class Cls {
  a = { name: "abc" }
}
const cls: Cls = new Cls();
const alsoAlsoBad: ThingMap = cls; // error!
// Index signature for type 'string' is missing in type 'Cls'.

And that's the problem you're running into. ThingMapClassA and ThingMapClassB are not assignable to ThingMap, even though an anonymous object literal type equivalent to either one would be. So you'll need to change what you're doing.


The easiest approach here is to change your constraint to be recursive. You don't need T to have a string index signature; you just want to know that its properties are assignable to Thing. That can be expressed as

class Handler<T extends Record<keyof T, Thing>> { }

using the Record<K, V> utility type. Record<keyof T, Thing> means "an object with the same keys as T, whose properties are of type Thing". So if T extends Record<keyof T, Thing>, then we know that every property of T is of type String.

So that gives us

const handler = new Handler<ThingMapClassA>(); // okay

and

const badHandler = new Handler<{ a: Thing, b: string }>(); // error!
// --------------------------> ~~~~~~~~~~~~~~~~~~~~~~~
// Types of property 'b' are incompatible.
// Type 'string' is not assignable to type 'Thing'

as desired.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360