nendlabsExperiments

computed objects, descriptors, types

cobject

Computed object fields become useful when the runtime and the TypeScript result type agree on what is data and what is derived.

The Definition Is the Interface

Computed values usually begin as helper functions beside plain data. That is fine until the helper becomes part of the object contract: display names, package lists, summaries, labels, or derived fields shared across many definitions.

CObject asks whether those values can live inside the object definition without losing plain-object composition. A definition can carry literals, arrays, nested objects, spreads, and functions. Evaluation returns an object that reads naturally, while function fields become computed values.

The important split is definition type versus result type. Authors write a shape that may contain functions. Callers read a shape where those functions have become values.

typescript
const base = {
  result: ({ a, b }) => a + b,
};

const computedA = CObject({
  ...base,
  a: "a",
  b: "b",
});

const computedB = CObject({
  ...base,
  a: "hello",
  b: " world",
});

computedA.result; // "ab"
computedB.result; // "hello world"

Evaluation Is a Descriptor Pass

The runtime is small because JavaScript already has the primitive it needs. CObject creates a result object, walks the own property names, turns functions into getters, recurses into nested objects and arrays, and defines ordinary values through descriptors.

There is no proxy layer and no dependency graph. Computation is lazy because accessors run on read. Nested values are evaluated with the same root object, so a computed field can derive from branches outside its local subtree.

typescript
function evaluate<Obj, Root>(object: Obj, root?: Root): Root {
  const result = createObject(object);
  const keys = getKeysOf(object);

  if (!root) root = result as Root;

  for (const key of keys) {
    const property = object[key];

    if (isFunc(property)) {
      Object.defineProperty(
        result,
        key,
        makePropertyDescriptor({
          get() {
            return property(root);
          },
        }),
      );
      continue;
    }

    if (Array.isArray(property) || isObj(property)) {
      Object.defineProperty(
        result,
        key,
        makePropertyDescriptor({ value: evaluate(property, root) }),
      );
    }
  }

  return result;
}

The Root Object Is the Shared Context

The nested tests show the intended use case better than a toy full-name example. A computed field can read across object branches because the root object is passed into the getter. That allows one field to summarize a person, occupation, and nested phone numbers without a global registry or separate selector layer.

The same mechanism makes composition feel like ordinary JavaScript. Shared computed fields can be spread into multiple objects, and aggregation can read nested repository collections from multiple sources.

typescript
CObject({
  person: {
    firstName: "John",
    lastName: "Doe",
    phone: {
      home: "555-555-5555",
      work: "555-555-5556",
    },
  },
  occupation: {
    title: "Software Engineer",
    company: "Google",
  },
  cta: ({ person, occupation }) =>
    `${person.firstName} ${person.lastName}, a ${occupation.title} at ${occupation.company} can be reached at ${person.phone.home} or ${person.phone.work}.`,
});

Types Turn Definitions Into Values

The type-level experiment is the real interface. CObject<T> maps function fields to their return types and recursively maps nested objects. CObjectDef<T, Parent> describes what authors are allowed to write before evaluation.

That means result.c can be typed as a number when c was defined as a function returning a number. The evaluated object is concrete even though the authoring object was mixed data and computation.

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

type CObjectDef<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];
};

The Boundary Is the Hard Part

The caveat is important. The runtime can technically read another computed getter, but the type system excludes function fields from computed arguments. That is the right conservative choice because dependency order, cycles, memoization, invalidation, and computed-to-computed semantics are not solved by the current implementation.

CObject is valuable because it keeps the small idea honest: descriptor-backed lazy fields, root-aware nested evaluation, plain object composition, and a mapped result type. It is not a reactive engine, async graph, or state manager. It is a compact experiment in making derived object state explicit.