Deriving object types based on arrays in Typescript


typescript type-safety

Arrays and objects are two fundamental building blocks in programming languages, each with their own tradeoffs:

Below I describe a useful technique that takes advantage of the benefits of both structures: deriving an object’s type from a single source of truth array describing the object, usable both as a value and a type:

const catInfoFields = [
  { key: 'name', type: 'string' },
  { key: 'age', type: 'number' },
  { key: 'isKitten', type: 'boolean', optional: true },
] as const;

// ... <described below> ...

type CatInfo = FieldsObject<typeof catInfoFields>;
const olivia: CatInfo = { name: 'Olivia', age: 6 };
const mozart: CatInfo = { name: 'Mozart', age: 1, isKitten: true };

Unions of literal types

A very rudimentary typing for CatInfo could just be a Record where we define string keys, and string, number, or boolean keys. While this somewhat restricts what could be assigned to these objects, it doesn’t really give us much benefit.

type CatInfo = Record<string, string | number | boolean>;
const cat: CatInfo = { name: 'Olivia', age: 6 };
const notCat: CatInfo = { brand: 'BMW', model: 'M3' }; // TODO: This is valid, but it shouldn't be.

First, let’s restrict the keys that could be assigned to this object, beyond just string.

It’s possible to constrain a string type to one of a few predefined values, using a union of literal types.

type Key = 'name' | 'age' | 'isKitten';

While this is useful, it’s limited: it only exists as a type. You cannot use it to list out all of the keys in a dropdown, or iterate through it to render form fields, since JavaScript won’t know of its existence once the TypeScript is compiled and the type annotations are removed.

A single source of truth for a type definition that bridges types and values is possible, using a readonly or const array.

const keys = ['name', 'age', 'isKitten'] as const;
type Key = (typeof keys)[number];

This works because the as const tells Typescript that this array will not have items appended or removed, and therefore the type introspection can be more specific. The array is more like a tuple (a finite set) when thus defined. Without the as const, typeof keys[number] would be inferred as string, since the compiler could not infer anything else about what other strings you might add.

Now we have bridged the type and the values:

// Using the type
const aKey: Key = 'name';

// Using the value
keys.forEach(console.log);

Deriving literal unions from arrays of objects

Extending this a bit further, a const array of objects can be flattened using a property. Here we find the possible values of key in an array that could also contains other properties.

const catInfoFields = [
  { key: 'name' },
  { key: 'age' },
  { key: 'isKitten' },
] as const;
type Key = (typeof catInfoFields)[number]['key'];

Knowing what values the key may have, we can then construct a Record that is an object restricted to those keys:

type CatInfo = Record<Key, unknown> = {}

Why the unknown? If we expected all of our values to be strings, we could define the object type as Record<Key, string>. In our example, we would like heterogenous value types to be enforced (string for "name", number for "age", and boolean for "isKitten"), so we need to extend our type definition a little bit.

Generalizing the definition

First, let’s slightly generalize our type, so that they could be used for any set of fields:

type Field = {
  key: string;
  type: 'string' | 'number' | 'boolean';
  optional?: boolean;
};

type FieldsObject<T extends readonly Field[]> = Record<
  T[number]['key'],
  unknown
>;

Note that the type field is not a type (number), but rather the literal "number". We will use this a little later to constrain the types allowed for the values of the object.

Using our generic FieldsObject type, we can derive an object type for any array of fields that we’d like, for example, the set of fields for dogs instead of cats:

const dogFields = [
  { key: 'name', type: 'string' },
  { key: 'isPuppy', type: 'boolean' },
  { key: 'breed', type: 'string', optional: true },
] as const;

type DogInfo = FieldsObject<typeof dogFields>;
const dog: DogInfo = { name: 'Jax', isPuppy: true }; // OK
const cat: DogInfo = { name: 'Mozart', isKitten: true }; // ERROR

Adding type-checking to values

While the FieldsObject definition above will enforce a few things about the object type keys (we can no longer introduce unknown keys), it doesn’t yet enforce type checking of the object’s values:

const cat: CatInfo = {
  name: 'Alice',
  age: 'Ancient', // TODO: This should be an error
};

To do this, we can use the Extract utility type, which behaves like a .filter() on a type union.

Using Extract, we can filter the types defined in our array to find the fields for which we’d expect the various value types.

Here, we extract all the fields from the catInfo array that have their type set to "boolean":

type CatBooleanFields = Extract<(typeof catInfo)[number], { type: 'boolean' }>;

Using that, we can extract the keys for these fields, and defining a record type for it:

type CatBooleanKeys = Extract<
  (typeof catInfo)[number],
  { type: 'boolean' }
>['name'];
type CatBooleanObj = Record<CatBooleanKeys, boolean>;

const kitten: CatBooleanObj = { isKitten: true }; // OK
const foo: CatBooleanObj = { isKitten: 17 }; // ERROR: The value is not a boolean.
const bar: CatBooleanObj = { age: 17 }; // ERROR: That key does not expect a boolean.

Given that we have three different types that we want to support ("string", "number", and "boolean"), we can now create separate a Record type for each, and union them. Putting it all together, this is what a more correct FieldsObject would look like:

type Field = {
  key: string;
  type: 'string' | 'number' | 'boolean';
  optional?: boolean;
};

type FieldsObject<T extends readonly Field[]> = Record<
  Extract<T[number], { type: 'string' }>['key'],
  string
> &
  Record<Extract<T[number], { type: 'number' }>['key'], number> &
  Record<Extract<T[number], { type: 'boolean' }>['key'], boolean>;

With this, we have type checking not only of the keys, but also the values assigned to our object:

const cat: CatInfo = {
  name: 'Olivia',
  age: 6,
  isKitten: false,
};

const badCat: CatInfo = {
  name: 'John',
  age: 'old', // ERROR
  isKitten: 'no', // ERROR
};

Allowing unset optional fields

There’s one last problem with the above definition of FieldsObject - it requires all keys to be set, even though we’d like keys from our fields array with the optional property to be, well, optional:

const cat: CatInfo = {
  name: 'Olivia',
  age: 6,
  // ERROR: TypeScript complains that "isKitten" is required.
};

No problem! We can use the Extract trick again, this time checking for whether the optional property is part of the field type. Unlike the case of the “type”, which is always present, we want to check whether the field is missing, so we use the inverse of Extract (Exclude):

type OptionalField<T extends Field> = Extract<T, { optional: true }>;
type RequiredField<T extends Field> = Exclude<T, { optional: true }>;

The generic object-from-array type definition

Incorporating this back into our generic definition, we end up with the following type (this can be directly copy-pasted into your code):

type Field = {
  key: string;
  type: 'string' | 'number' | 'boolean';
  optional?: boolean;
};
type Fields = readonly Field[];

type OptionalField<T extends Field> = Extract<T, { optional: true }>;
type RequiredField<T extends Field> = Extract<T, { optional?: never | false }>;

type FieldsObject<T extends Fields> = Record<
  Extract<RequiredField<T[number]>, { type: 'string' }>['key'],
  string
> &
  Record<Extract<RequiredField<T[number]>, { type: 'number' }>['key'], number> &
  Record<
    Extract<RequiredField<T[number]>, { type: 'boolean' }>['key'],
    boolean
  > &
  Partial<
    Record<Extract<OptionalField<T[number]>, { type: 'string' }>['key'], string>
  > &
  Partial<
    Record<Extract<OptionalField<T[number]>, { type: 'number' }>['key'], number>
  > &
  Partial<
    Record<
      Extract<OptionalField<T[number]>, { type: 'boolean' }>['key'],
      boolean
    >
  >;

// Example usage below

const catInfoFields = [
  { key: 'name', type: 'string' },
  { key: 'age', type: 'number' },
  { key: 'isKitten', type: 'boolean', optional: true },
] as const;

type CatInfo = FieldsObject<typeof catInfoFields>;
const olivia: CatInfo = { name: 'Olivia', age: 6 };
const mozart: CatInfo = { name: 'Mozart', age: 1, isKitten: true };

Is it verbose? Yes. But is it useful? Also yes.

My suspicion is that this is something you will set up one time and then never touch again, and could surely also generate this definition using a script or code if you find it difficult to maintain.


The benefits of bridging the gap between array values and object types are substantial: the array value side of it can be used to automatically render UI components or run validation code, and the object typing side lets you maintain type safety when working with the resultant data.