Computed Objects With Typescript

Aug 31, 2022·5 min read

A computed object is one which contains fields that are evaluated on the fly. A simple example of one is the following:

type PersonDef = {
	name: string;
	age: number;
	isAdult: (p: any) => boolean;
};

const person = ComputedObject<PersonDef>({
	name: 'John',
	age: 30,
	isAdult: (p: any) => p.age >= 18,
});

To implement this, we'll look at the solution in two parts. First, we'll evaluate the fields to generate the computed result, then we'll type the ComputedObject function and it's return type.

Evaluating the input

The input object contains concrete fields, and fields that require computation. The computed fields can be idenficated by checking that the property type is a function. To evaluate these fields, we can traverse the object in search of computed fields.

In the following definition, the isAdult field is the only one that requires computation.

const person = {
	name: 'John',
	age: 30,
	isAdult: (p: any) => p.age >= 18,
};

We can also go another level deep, by introducing nesting.

const person = {
  name: 'John',
  age: 30,
  isAdult: (p: any) => p.age >= 18,
  address: {
    city: 'New York',
    state: 'NY',
    country: 'USA'
    full: ({city, state, country}) => `${city}, ${state}, ${country}`
  }
};

In this nested example, the fields isAdult as well as full require computation.

Traversing the fields

We can traverse the fields of an object by using the Object.getOwnPropertyNames function. This provides a list of properties which we can type-check and possibly evaluate.

const person = {
  name: 'John',
  age: 30,
  isAdult: (p: any) => p.age >= 18,
  address: {
    city: 'New York',
    state: 'NY',
    country: 'USA'
    full: ({city, state, country}) => `${city}, ${state}, ${country}`
  }
};

function evaluate(input, root) {
  const keys = Object.getOwnPropertyNames(input);

  keys.forEach(key => {
    const property = person[key];
    if (isFunction(property)) {
      console.log(`${key} is a computed field`);
    } else if (isArray(property) || isObject(property)) {
      evaluate(property, root);
    } else {
      console.log(`${key} is a concrete field`);
    }
  }
}

With this traversal we should be able to identify all fields that require computation, including nested fields. However, in order to generate the result of the computation, we have to redefine function properties with the result of their evaluation.

function evaluate<Input, Root>(input: Input, root?: Root) {
  // create an empty object to hold the result
  const result = Object.create(null);

  const keys = getKeysOf(input);

  keys.forEach(key => {
    const property = person[key];

    const defineProperty = (descriptor: ObjectDescriptor) => {
      Object.defineProperty(
        result,
        key,
        makePropertyDescriptor(descriptor)
      );
    }

    if (isFunction(property)) {
      defineProperty({
        get() {
          return property(root);
        },
      });

			continue;
    } else if (isArray(property) || isObject(property)) {
      defineProperty({
        value: evaluate(property, root),
      });

      continue;
    } else {
      defineProperty({
        value: property,
      });
    }
  }
}

Breaking it down

There are a couple of details to elaborate on with the above implementation.

Getting keys from the object

We need a robust method for getting the keys of an object and this is defined in getKeysOf.

function getKeysOf<O, K extends (keyof O)[]>(obj: O): K {
	if (isObj(obj)) {
		return Object.getOwnPropertyNames(obj) as K;
	} else {
		return Object.keys(obj) as K;
	}
}

The Object.getOwnPropertyNames will return all keys including those that belong to properties which are not enumerable, while Object.keys will omit fields that have enumerable set to true.

Defining properties on the result

This is handled by using the makePropertyDescriptor function, which is a wrapper around Object.assign and includes standardized fields for defining an object property.

function makePropertyDescriptor(descriptor: ObjectDescriptor) {
	return Object.assign(descriptor, {
		configurable: true,
		enumerable: true,
	});
}

Typing the ComputedObject function

Typing the function requires creating two distinct types, one for the Definition (or input) and one for the Result. These types can then be passed to the evaluate function which we already typed above.

function ComputedObject<T>(input: Definition<T>): Result<T> {
	return evaluate<Definition<T>, Result<T>>(input);
}

Definition

The type for Definition is as follows:

type Definition<T, Parent = T> = {
	[K in keyof T]: T[K] extends (arg: any) => infer Return
		? (arg: Exclude<T & Parent, Function>) => Return
		: T[K] extends object
		? CObjectDef<T[K], Parent>
		: T[K];
};

This type looks at each property of T and determines if it is a function, an object or a primitive. Each case is then handled differently:

Result

The type for Result is as follows:

type Result<T> = {
	[K in keyof T]: T[K] extends (arg: T) => infer Return
		? Return
		: T[K] extends object
		? Result<T[K]>
		: T[K];
};

This type looks at each property of T and determines if it is a function, an object or a primitive. Each case is then handled differently: