Arrays and objects are two fundamental building blocks in programming languages, each with their own tradeoffs:
- Arrays capture the intention of order, but do not allow fast access to individual items.
- Objects (in JavaScript at least), do not have order, but allow quick retrieval of any object by its key.
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.