javascripttypescriptgenericstypescript-genericsindex-signature

Generic type with any property of certain type in typescript


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? 😊


Solution

  • 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