Robby Cornelissen
Robby Cornelissen

Reputation: 97247

How to dynamically create an object based on a readonly tuple in TypeScript?

In TypeScript, I can define the properties of a type based on a readonly tuple with the following construct:

const keys = ['A', 'B', 'C'] as const;

type T = { [key in typeof keys[number]]: boolean; };

This behaves as expected:

const t1: T = {A: true, B: true, C: true}; // OK
const t2: T = {A: true, B: true}; // Type error

Now, let's say that I want to dynamically create an object of type T based on the keys array, e.g. using reduce() or ES2019's Object.fromEntries():

const t1: T = keys.reduce((a, v) => ({ ...a, [v]: true }), {}); // Type error
const t2: T = Object.fromEntries(keys.map(s => [s, true])); // Type error

In both cases, I need an explicit type assertion to stop the compiler from complaining:

const t1: T = keys.reduce((a, v) => ({ ...a, [v]: true }), {}) as T; // OK
const t2: T = Object.fromEntries(keys.map(s => [s, true])) as T; // OK

Can anyone suggest a way to declare and initialize an object of type T based on the keys array, without having to resort to an object literal or explicit type assertion?

Upvotes: 0

Views: 1621

Answers (3)

kaya3
kaya3

Reputation: 51092

The simplest solution is to use Object.create(null) instead of {} to initialise the object. This is cheating a bit, because Object.create has return type any which means you don't get a type error simply because the type isn't checked.

function makeObject<K extends PropertyKey, V>(keys: K[], value: V): Record<K, V> {
  const obj: Record<K, V> = Object.create(null);
  for(const k of keys) {
    obj[k] = value;
  }
  return obj;
}

The upside is that because the object has null as its prototype, it doesn't inherit properties like 'toString' which might mess up code which expects dynamically-accessed properties to definitely have type V if they exist. If you explicitly do want to inherit those properties, you can use Object.create({}) instead.

Upvotes: 3

I agree with @ABOS, that in your case it can be useful to consider augmenting of global types, however, I understand, that some times it is not an option - strict eslint rules, team code guides etc ...

I know only one solution to type correctly Array.prototype.reduce in your particular case:

const keys = ['A', 'B', 'C'] as const;

type T = { [key in typeof keys[number]]: boolean; };

const t1: T = { A: true, B: true, C: true }; // OK
const t2: T = { A: true, B: true }; // Type error

const result = keys.reduce<T>((a, v) => ({ ...a, [v]: true }), {} as T) // T

You just need to explicitly cast initial value of reduce. This problem is also related to typing of setState method in react. See open issue here.

Regarding to Array.prototype.map.

In your case you can type your mapping function in next way:

type Mapped<Arr extends ReadonlyArray<unknown>, Result extends ReadonlyArray<unknown> = []> =
    Arr extends []
    ? [] : Arr extends [infer H]
    ? readonly [...Result, readonly [H, true]] : Arr extends readonly [infer Head, ...infer Tail]
    ? Mapped<readonly [...Tail], readonly [...Result, readonly [Head, true]]> : Readonly<Result>

type Result = Mapped<MyArray>; //  readonly [readonly ["A", true], readonly ["B", true], readonly ["C", true], readonly ["D", true]]

/**
 * Because arrays are mutable in JS,
 * you are unable to type result variable as Result directly
 * 
 * One thing you can do is to use cast operator - as
 * I hope you don't mutate your arrays inside [map] callback
 */
const result = keys.map<Result[number]>((s) => ([s, true] as const)) as unknown as Result

Regarding fromEntries.

Same as Object.keys, fromEntries will not return correctly typed keys of source objects, it made purposely by TS team, because, like I already said, JS mutable nature.

You have two ways here, the way which was introduced by @ABOS, or type casting - through as operator.

For example, there is a common pattern of using Object.keys:

const obj = { foo: 1, bar: 2 }
const keys = Object.keys(obj) as Array<keyof typeof obj> // ("foo" | "bar")[]

Please keep in mind, it is still workaround, it is not 100% type safe, because of mutable nature of JS.

Here you can find more about Object.keys

I know, you did not ask about, Object.keys, but it is still same problem as fromEntries

Summary: There are two ways: augmenting global type definition or using type casting. Each way is not completely safe, but if you are not mutating your arrays/objects it is ok to use it.

P.S. I believe there are more ways to achieve it, just because I prefer functional programming, I did not provide any example with mutable data structures, but if you are ok with mutating, @kaya3 solution is good

Upvotes: 2

ABOS
ABOS

Reputation: 3823

Perhaps you can consider augmenting type definition of ObjectConstructor

interface ObjectConstructor {
  fromEntries<T = any, K=string|number>(entries: Iterable<readonly [PropertyKey, T]>): { [k in keyof K]: T };
}
const keys = ['A', 'B', 'C'] as const;
type T = { [key in typeof keys[number]]: boolean; };
const t2: T = Object.fromEntries<boolean, T>(keys.map(s => [s, true])); // no Type error

Upvotes: 3

Related Questions