Combining generics with index type

Let’s talk about index signature types and mapped types. They have similar syntax and do similar-ish things, but they’re not the same. Here are the similarities:

  • They are both object types representing a range of properties

  • Syntax: both index signatures and mapped types use bracketed keylike notation within an object type, as in {[Some Key-like Expression]: T}

Now for the differences:

INDEX SIGNATURES

Index signatures describe part of an object type or interface representing an arbitrary number of properties of the same type, with keys from a certain key type. Currently, these key type can only be exactly string, number, or symbol, or a “pattern template literal” types as implemented in ms/TS#40598 like `foo_${string}`, or a union of these.

  • Syntax: The syntax for an index signature looks like this:

      type StringIndex<T> = {[dummyKeyName: string]: T}
      type NumberIndex<T> = {[dummyKeyName: number]: T} 
    

    There is a dummy key name (dummyKeyName above) which can be whatever you want and does not have any meaning outside the brackets, followed by a type annotation (:) of either string or number.

  • Part of an object type: an index signature can appear alongside other properties in an object type or interface:

      interface Foo {
        a: "a",
        [k: string]: string
      }
    
  • Arbitrary number of properties: an object of an indexable type is not required to have a property for every possible key (which is not even really possible to do for string or number aside from Proxy objects). Instead, you can assign an object containing an arbitrary number of such properties to an indexable type. Note that when you read a property from an indexable type, the compiler will assume the property is present (as opposed to undefined), even with --strictNullChecks enabled, even though this is not strictly type safe. Example:

      type StringDict = { [k: string]: string };
      const a: StringDict = {}; // no properties, okay
      const b: StringDict = { foo: "x", bar: "y", baz: "z" }; // three properties, okay
      const c: StringDict = { bad: 1, okay: "1" }; // error, number not assignable to boolean
    
      const val = a.randomPropName; // string
      console.log(val.toUpperCase()); // no compiler warning, yet
      // "TypeError: val is undefined" at runtime
    
  • Properties of the same type: all of the properties in an index signature must be of the same type; the type cannot be a function of the specific key. So “an object whose property values are the same as their keys” cannot be represented with an index signature as anything more specific than {[k: string]: string}. If you want a type that accepts {a: "a"} but rejects {b: "c"}, you can’t do that with an index signature.

  • Only string, number, symbol, or a pattern template literal is allowed as the key type: you can use a string index signature to represent a dictionary-like type, or a number index signature to represent an array-like type. TypeScript 4.4 introduced support for symbol and pattern template literals, and unions of these.

You can’t narrow the index signature to a particular set of string or number literals like "a"|"b" or 1|2. (Your reasoning about why it should accept a narrower set is plausible but that’s not how it works. The rule is that no member of an index signature parameter type can be a “singleton” or “unit” literal type.

MAPPED TYPES

A mapped type on the other hand describes an entire object type, not an interface, representing a particular set of properties of possibly varying types, with keys from a certain key type. You can use any key type for this, although a union of literals is most common (if you use string or number, then that part of the mapped type turns into… guess what? an index signature!) In what follows I will use only a union of literals as the key set.

  • Syntax: The syntax for a mapped type looks like this:

      type Mapped<K extends keyof any> = {[P in K]: SomeTypeFunction<P>};
      type SomeTypeFunction<P extends keyof any> = [P]; // whatever
    

    A new type variable P is introduced, which iterates over each member of the union of keys in the key set K. The new type variable is still in scope in the property value SomeTypeFunction<P>, even though it’s outside the brackets.

  • An entire object type: a mapped type is the entire object type. It cannot appear alongside other properties and cannot appear in an interface. It’s like a union or intersection type in that way:

      interface Nope {
          [K in "x"]: K;  // errors, can't appear in interface
      }
      type AlsoNope = {
          a: string,
          [K in "x"]: K; // errors, can't appear alongside other properties
      }
    
  • A particular set of properties: unlike index signatures, a mapped type must have exactly one property per key in the key set. (An exception to this is if the property happens to be optional, either because it’s mapped from a type with optional properties, or because you modify the property to be optional with the ? modifier):

      type StringMap = { [K in "foo" | "bar" | "baz"]: string };
      const d: StringMap = { foo: "x", bar: "y", baz: "z" }; // okay
      const e: StringMap = { foo: "x" }; // error, missing props
      const f: StringMap = { foo: "x", bar: "y", baz: "z", qux: "w" }; // error, excess props
    
  • Property types may vary: because the iterating key type parameter is in scope in the property type, you can vary the property type as a function of the key, like this:

      type SameName = { [K in "foo" | "bar" | "baz"]: K };
      /* type SameName = {
          foo: "foo";
          bar: "bar";
          baz: "baz";
      } */
    
  • Any key set may be used: you are not restricted to string, number, symbol or pattern template literals. You can use any set of string literals or number literals. You can also use string or number in there, but you immediately get an index signature when that happens:

      type AlsoSameName = { [K in "a" | 1]: K };    
      /* type AlsoSameName = {
          a: "a";
          1: 1;   
      } */
      const x: AlsoSameName = { "1": 1, a: "a" }
    
      type BackToIndex = { [K in string]: K }
      /* type BackToIndex = {
          [x: string]: string;
      }*/
      const y: BackToIndex = { a: "b" }; // see, widened to string -> string
    

    And since any key set may be used, it can be generic:

      type MyRecord<Key extends string, Value> = { [P in Key]: Value };
    

So that’s how you would make MyRecord. It can’t be an indexable type; only a mapped type. And note that the built-in Record<K, T> utility type is essentially the same (it allows K extends string | number | symbol), so you might want to use that instead of your own.

Link to code

Leave a Comment