How to create type with one or more keys with string value whilst allowing other fields of any type?

Viewed 50

I am making a combobox component which takes two parameters:

  1. options: a series of objects to select from
  2. displaytextKeyname: a string which represents the name of a key to look at within each option which contains a string to display as the name

Here is a simplified example of this in use:

type ComboboxProps<T> = {
    options: T[];
    displaytextKeyname: string;
}

const Combobox = ({
    options,
    displaytextKeyname
}:ComboboxProps<unknown>) => {
    options.forEach(o => console.log(o[displayTextKeyname])
}

I am looking to make an object that would be suitable as type T above. The idea is this is designed in such a generic way that said field could be called "description", "longname", "name" or whatever. All that is required is that it is a string.

Currently I have attempted it in the following ways:

Approach 1 - require all string values

type ComboboxOption = {
    [key: string]: string;
};

const foo: ComboboxOption = {
    a: "123",
    b: 456, // Type 'number' is not assignable to type string
};

Approach 2 - allow all values

type ComboboxOption = {
    [key: string]: unknown;
};

const foo: ComboboxOption = {
    a: "123",
    b: 123,
};

// This allows objects which do not have at least one key with a string value
const bar: ComboboxOption = {
    b: 123,
};

Approach 3 - extend interface

interface IComboboxOption {
    [key: string]: string;
}

//Interface 'IComboboxOptionExt' incorrectly extends interface 'IComboboxOption'.
  //'string' index signatures are incompatible.
    //Type 'unknown' is not assignable to type 'string'
interface IComboboxOptionExt extends IComboboxOption {
    [key: string | number | symbol]: unknown;
}

Is this beyond typescript's type system or am I just thinking about this entirely wrong?

2 Answers

Yes, such a constraint is indeed too much for TypeScript types. A stand-alone type will not be able to express this behaviour.

But it is possible to achieve things like this with a generic function. This would be my approach:

function Combobox<
  Key extends string
>(key: Key, options: (Record<string, any> & Record<Key, string>)[]) {}

This will validate the passed array based on the constraint you specified.

Combobox("description", [{
  "description": "abc",
  a: 123
}])

Combobox("longname", [{
  "longname": "abc",
  a: 123
},{
  "longname": "efg",
  b: 456
}, {
  c: 123 // Error: '{ c: number; }' is not assignable to type 'Record<string, any> & Record<"longname", string>'
}])

Playground

While @TobiasS.'s answer is perfectly valid, it is possible to build a solution that is much closer to your initial description:

  • using object destructuring, for a more React component-like signature (can be easily adapted to @TobiasS.'s answer as well)
  • using a standalone type, without necessarily applying it to a function (but only when used with a function can the generic be automatically inferred)
// Use a generic for the key instead of the array type
type ComboboxProps<Key extends string> = {
  displaytextKeyname: Key,
  // Constrain the array type using the key, like in @TobiasS.'s answer
  options: Array<Record<string, any> & { [k in Key]: string }>
}

Example using the type without a function:

// Using the type standalone,
// but we have to explicitly specify the generic type
const ComboboxObject: ComboboxProps<"hello"> = {
  displaytextKeyname: "hello",
  options: [{
    hello: "world",
    foo: 42
  }, {
    // @ts-expect-error
    hello: false // Type 'boolean' is not assignable to type 'string'.
  },
  // @ts-expect-error
  {
    foo: "bar" // Type '{ foo: string; }' is not assignable to type 'Record<string, any> & { hello: string; }'. Property 'hello' is missing in type '{ foo: string; }' but required in type '{ hello: string; }'.
  }]
}

Example using the type in a function:

// Using the type for a function argument,
// the generic can be automatically inferred.
const Combobox = <Key extends string>({
  options,
  displaytextKeyname
}: ComboboxProps<Key>) => {
  options.forEach(o => console.log(o[displaytextKeyname]))
}

Combobox({
  displaytextKeyname: "description",
  options: [{
    "description": "abc",
    any: true
  }]
})

Combobox({
  displaytextKeyname: "longname",
  options: [{
    "longname": "abc",
    a: 123
  }, {
    // @ts-expect-error
    "longname": 4, // Type 'number' is not assignable to type 'string'.
    b: 456
  },
  // @ts-expect-error
  {
    c: 123 // Type '{ c: number; }' is not assignable to type 'Record<string, any> & { longname: string; }'. Property 'longname' is missing in type '{ c: number; }' but required in type '{ longname: string; }'.
  }]
})

Playground Link

Related