typescripttypesindex-signature

How to specify an interface that must have at least two properties of specific types


This is for the options of a select box. The options must each have one string property that serves as an id, and another property that represents what will be displayed -- in this case a ReactNode, however, I don't care what these are called only that there are two properties that meet this criteria. I've tried the following:

type Node = string | Array<string>;

interface SelectOption {
  [id: string]: string;
  [el: string]: Node;
}

However when I try this Typescript produces the following error: Duplicate index signature for type 'string'.

This doesn't produce an error:

export interface SelectOption {
  [index: string]: string | Node;
}

but is too permissive since it will match an object with only one property (and also redundant because ReactNode includes string).

Is there a way to specify types for two unnamed properties?


Solution

  • I strongly suggest you refactor your data structure into a format that plays well with the TypeScript type system and the JavaScript runtime. Here is the shape I'd expect SelectOption to have:

    interface SelectOption {
      idKey: string,
      idValue: string,
      elKey: string,
      elValue: Node
    }
    

    Now you know exactly what the key for each value is going to be. If you need to process one of these things, you can do so easily:

    function processSelectOption(selectOption: SelectOption) {
      console.log("id at key " + selectOption.idKey +
        " with string value \"" + selectOption.idValue + "\"");
      console.log("el at key " + selectOption.elKey +
        " with Node value " + JSON.stringify(selectOption.elValue));
    }
    
    processSelectOption({ idKey: "id", idValue: "xyz", elKey: "el", elValue: node });
    // id at key id with string value "xyz" 
    // el at key el with Node value ["a","b","c"]
    

    Compare this to what you'd need to do with your current data structure, where all you know is that some key has a value of type string and some other key has a value of type Node:

    function processSelectOption(selectOption: any) {
    // for now, let's use any ---------------> ^^^
      function isString(x: any): x is string { return typeof x === "string" }
      function isNode(x: any): x is Node {
        return ["string", "function"].includes(typeof x) ||
          (Array.isArray(x) && x.every(w => ["string", "function"].includes(typeof w)));
      }
      function findSelectOptionData(selectOption: any) {
        for (const idKey in selectOption) {
          for (const elKey in selectOption) {
            if (elKey === idKey) continue;
            const idValue = selectOption[idKey];
            if (!isString(idValue)) continue;
            const elValue = selectOption[elKey];
            if (!isNode(elValue)) continue;
            return { idKey, idValue, elKey, elValue };
          }
        }
        return;
      }
      const selectOptionData = findSelectOptionData(selectOption);
      if (!selectOptionData) throw new Error("COULDN'T FIND IT");
      // now selectOptionData is the same as the SelectOption I proposed above
      console.log("id at key " + selectOptionData.idKey +
        " with string value \"" + selectOptionData.idValue + "\"");
      console.log("el at key " + selectOptionData.elKey +
        " with Node value " + JSON.stringify(selectOptionData.elValue));
    }
    

    See how we have to write runtime tests to identity string and Node values, because they could be on any properties. And we also have to iterate over all pairs of properties of selectOption to find one that's a string and a different one that's a Node. (So if selectOption has 𝑛 keys, then you are iterating over O(𝑛​²) elements to identify the properties.) And once you've done all that, you've finally got the same four pieces of information from the SelectOption interface I originally proposed:

    processSelectOption({ id: "xyz", el: node });
    // id at key id with string value "xyz" 
    // el at key el with Node value ["a","b","c"]    
    

    And then even once you do this, you might get surprising results:

    processSelectOption({ el: "node", id: "str" });
    // id at key el with string value "node"
    // el at key id with Node value "str"
    

    Since string extends Node, there's no way to look at a pair of string properties and figure out which one is "supposed" to be the id and which one is supposed to be the element. So you have to do a lot of processing just to get to a place where there's ambiguity over what is supposed to be doing what. That ambiguity gets worse with more properties:

    processSelectOption({ foo: 123, bar: "baz", id: "str", el: node });
    // id at key bar with string value "baz"
    // el at key id with Node value "str"
    

    Without knowing your complete use case, I can't be certain, but from the outside it looks like such a data structure should be a non-starter. And that's just looking at runtime.


    In the type system, the same weird ambiguity exists. The language isn't really geared toward counting how many properties exist and if there is one property of type A and some distinct property of type B. There is certainly no specific type that captures this notion, so any hope of writing interface SelectOption {/*...*/} or type SelectOption = ... is lost.

    You can express this as a sort of constraint on a type. If you have a candidate type T, you can write a generic type called AsValidSelectOption<T> which takes in the candidate type T and produces a valid SelectOption type which is "close" to T in some ill-defined sense. If T is valid, then we want to be sure that T extends AsValidSelectOption<T>. If T is not valid, then we want AsValidSelectOption<T> to be something valid that's "close" to T so that the error messages mention what's wrong in a user-friendly-ish way.

    Let's look into that now:


    First, let's write AtLeastTwoElements<K> which takes a union of key-like types L and evaluates to the unknown top type if there are at least two elements in the K union, or else the never bottom type id there are fewer than two elements:

    type AtLeastTwoElements<K extends PropertyKey> =
      { [P in K]: { [Q in Exclude<K, P>]: unknown }[Exclude<K, P>] }[K];
    

    This is a nested mapped type where in the inner types we use the Exclude utility type to successively remove keys from K. If we can do that once and still have keys left over, then there are at least two keys in that union. AtLeastTwoElements<"a" | "b"> evaluates to {a: {b: unknown}["b"], b: {a: unknown}["a"]}["a" | "b"] which is {a: unknown, b: unknown}["a" | "b"] which is unknown. But AtLeastTwoElements<"a"> is {a: {}[never], b: {}[never]}["a" | "b"] which is {a: never, b: never}["a" | "b"] which is never. And AtLeastTwoElements<never> is {}[never] which is never.


    Then, we write ValidSelectOptionsWithKeys<K> which takes a union of keylike types K and produces a big union of all possible valid SelectOption types with these keys:

    type ValidSelectOptionsWithKeys<K extends PropertyKey> = { [P in K]:
      Record<P, Node> & { [Q in Exclude<K, P>]: Record<Q, string> }[Exclude<K, P>]
    }[K] extends infer O ? O extends any ? { [P in keyof O]: O[P] } : never : never;
    

    That might look complicated, but it's actually quite similar to the way findSelectOptionData() works above, by iterating over every key and treating it as a Node, and then iterating over every remaining key and treating it as a string. If there are exactly two keys "a" | "b" then this is evaluated to something like {a: {a: Node}&{b: {b: string}}["b"], b: {b: Node}&{a: {a: string}["a"]}}["a" | "b"] which is {a: {a: Node, b: string}, b: {b: Node, a: string}}["a" | "b"] which is {a: Node, b: string} | {a: string, b: Node}. The number of possibilities grows with the number of entries in K. For three keys you have something like {a: Node, b: string} | {a: Node, c: string} | {b: Node, a: string} | {b: Node, c: string} | {c: Node, a: string} | {c: Node, b: string}. So if K has 𝑛 elements then the type produced is a union of O(𝑛​²) elements.


    Finally we build AsValidSelectOption<T>:

    type AsValidSelectOption<T extends object> =
      unknown extends AtLeastTwoElements<keyof T> ? ValidSelectOptionsWithKeys<keyof T> :
      T & (
        "anotherProp" extends keyof T ? { someOtherProp: Node | string } :
        { anotherProp: Node | string }
      );
    

    If T has at least two elements, then we evaluate ValidSelectOptionsWithKeys<keyof T>, which T had better be assignable to if it is valid. If T has fewer than two elements, then we evaluate T & {anotherProp: Node | string} which T will almost certainly fail to extend, and the error message will complain that anotherProp is missing. Oh, unless you actually happened to name your one key anotherProp, then we complain about someOtherProp. It's probably very unlikely but at least we've covered the bases.


    In order to test whether some proposed value of type T extends AsValidSelectOption<T>, we need a generic helper function to pass it to, since only generic functions will infer T for us rather than forcing us to manually specify it. Here's the function asSelectOption:

    const asSelectOption =
      <T extends object>(
        opt: T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>
      ) => opt as T;
    

    Ideally I'd like to write <T extends AsValidSelectOption<T>>(opt: T) => opt, but that's a circular constraint. Instead, we only constrain T to object, but then have opt be of the conditional type T extends AsValidSelectOption<T> ? T : AsValidSelectOption<T>. This will tend to make the compiler choose T to be the type of opt and then test it. It's an inference trick.


    So that was quite a lot of crazy effort to hopefully capture the concept of "one property of type string and some distinct property of type Node". Let's at least see if it works:

    declare const node: Node;
    
    const okay0 = asSelectOption({ a: "", b: node }); 
    const okay1 = asSelectOption({ x: node, y: "" });
    const okay2 = asSelectOption({ g: "", h: "" });
    const okay3 = asSelectOption({ a: "", b: node, c: 123 })
    
    const bad0 = asSelectOption({ a: "", b: 1 }); // number is not Node  
    const bad1 = asSelectOption({ a: node, b: node }); // error! 
    // Argument of type '{ a: Node; b: Node; }' is not assignable to 
    // parameter of type '{ a: Node; b: string; } | { b: Node; a: string; }'
    const bad2 = asSelectOption({}) // Property 'anotherProp' is missing
    const bad3 = asSelectOption({ a: "" }) //  Property 'anotherProp' is missing
    const bad4 = asSelectOption({ anotherProp: "" }) // Property 'someOtherProp' is missing
    

    Well, that's good, at least. The okay* lines compile without error, because each object conforms to your constraint. The bad* lines have errors in them for one reason or another. Hooray, I guess! But oof, at what cost?


    So there you go. If you jump through a lot of crazy hoops both at compile time and at run time, you end up with an ambiguous, fragile, and confusing implementation that enforces your constraint and handles (checks notes) four values. If you refactor your data structure to

    interface SelectOption {
      idKey: string,
      idValue: string,
      elKey: string,
      elValue: Node
    }
    

    then you have a straightforward task at both compile time and at run time where the four pieces of relevant information are always in statically known places, and the implementations are robust. Maybe your use case really makes hoop-jumping more desirable than refactoring, but again, from the outside, I'd be very wary of a project with something like asSelectOption() in it.

    Playground link to code