---
title: 'Type Object and Attribute Variants'
source: 'https://academia.sh/en/courses/design-patterns/type-object-and-attribute-variants'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:15+00:00'
license: 'CC BY-SA 4.0'
---

# Type Object and Attribute Variants

Modeling the type at runtime: comparing the arrangement that writes a class for every service type against the type object that carries the type as data; counting the number of code files carrying the type name and the cost of adding a new type; runtime validation of the property bag and the cost of the write-time check it gives up.

The six patterns so far assumed there was a single shipment type. Once a second service type
enters the library — a cold chain shipment, a valuable-goods shipment, a document shipment —
each type gets its own attributes and its own fee multiplier. Writing the type into code is a
decision that increases the number of files edited as the type count grows; if the types come
from a data source, writing code is already impossible.

**Type Object** models the type not as a class but as an object: the type's name, the attributes
it requires, and its fee behavior become data. **Attribute Variants** is the second half of this:
every type carrying a different attribute set is solved by having the shipment record hold a
**property bag** instead of fixed fields, and the bag's validity is asked of the type object.

## One Class per Type

In the first arrangement, every service type is a class; the attribute list and the multiplier
sit inside the class.

```js
// classes/types.mjs — every service type is a class; attribute list and multiplier are in the code
export class Standard {
  static attributes = [];
  fee(s) { return Math.round(s.base); }
}

export class ColdChain {
  static attributes = ["temperature"];
  fee(s) { return Math.round(s.base * 1.6); }
}

export class Valuable {
  static attributes = ["insuredValue"];
  fee(s) { return Math.round(s.base * 1.25 + s.attributes.insuredValue * 0.01); }
}
```

```js
// classes/registry.mjs — registry that binds the type key to the class
import { Standard, ColdChain, Valuable } from "./types.mjs";

const REGISTRY = { "standard": Standard, "cold-chain": ColdChain, "valuable": Valuable };

export function fee(s) {
  const Type = REGISTRY[s.type];
  if (Type === undefined) throw new RangeError(`unknown service type: ${s.type}`);
  return new Type().fee(s);
}
```

The arrangement's gain is that every type keeps its behavior in its own text. Its loss is that
the type set is part of the code: a new type means editing both files.

## Type Object

In the second arrangement, the type is an object. The code knows no type name; it only knows
what kind of thing a type is.

```js
// type/type-object.mjs — the type is modeled as an object; no type name appears in the code
export class TypeObject {
  constructor({ name, attributes, multiplier, extra }) {
    Object.assign(this, { name, attributes, multiplier, extra: extra ?? null });
  }
  validate(s) {
    const bag = s.attributes ?? {};
    return [
      ...this.attributes.filter((n) => n in bag === false).map((n) => `missing attribute: ${n}`),
      ...Object.keys(bag).filter((n) => this.attributes.includes(n) === false).map((n) => `unrecognized attribute: ${n}`),
    ];
  }
  fee(s) {
    const extra = this.extra === null ? 0 : (s.attributes?.[this.extra.field] ?? 0) * this.extra.rate;
    return Math.round(s.base * this.multiplier + extra);
  }
}

export const typeCatalog = (json) => new Map(JSON.parse(json).map((t) => [t.name, new TypeObject(t)]));
```

The types themselves are data. The file below is not a code file, it is a file that carries
data; the same text could just as well have come from a configuration record or a table.

```js
// type/type-data.mjs — service types as data: every row is a type
export const TYPE_JSON = `[
  { "name": "standard", "attributes": [], "multiplier": 1 },
  { "name": "cold-chain", "attributes": ["temperature"], "multiplier": 1.6 },
  { "name": "valuable", "attributes": ["insuredValue"], "multiplier": 1.25, "extra": { "field": "insuredValue", "rate": 0.01 } }
]`;
```

The `extra` field is how attribute variants factor into the fee: for a valuable-goods shipment,
one percent of the insured value is added to the fee, and no such rule exists for the other
types. In the class-based arrangement, this rule was a method body; here it is a two-field
record.

## Measurement

Three things are counted: how many code files the type name sits in, what adding a new type
costs, and how many invalid attributes get caught.

```js
// count-types.mjs — how many code files carry the type name, and the cost of adding a new type
import { readFileSync } from "node:fs";
import { fee as classFee } from "./classes/registry.mjs";
import { typeCatalog } from "./type/type-object.mjs";
import { TYPE_JSON } from "./type/type-data.mjs";

const TYPE_NAME = /Standard|ColdChain|Valuable|"(standard|cold-chain|valuable)"/;
const CODE = { "class-based": ["classes/types.mjs", "classes/registry.mjs"], "type object ": ["type/type-object.mjs"] };

for (const [name, files] of Object.entries(CODE)) {
  const texts = files.map((f) => readFileSync(f, "utf8"));
  const touched = texts.filter((m) => TYPE_NAME.test(m)).length;
  const lists = texts.reduce((t, m) => t + [...m.matchAll(/static attributes|"attributes"/g)].length, 0);
  console.log(`${name}: code file carrying type name = ${touched} / ${files.length}, attribute list in code = ${lists}`);
}
console.log(`type data   : ${JSON.parse(TYPE_JSON).length} types, type/type-data.mjs is a data file`);

const DOCUMENT = `{ "name": "document", "attributes": ["pages"], "multiplier": 0.7 }`;
const catalog = typeCatalog(`[${TYPE_JSON.slice(1, -1).trim()},\n  ${DOCUMENT}\n]`);
const s = { type: "document", base: 8640, attributes: { pages: 4 } };

let classResult;
try { classResult = String(classFee(s)); } catch (e) { classResult = `error: ${e.message}`; }
console.log(`\nnew type "document" added (1 data row, 0 code lines)`);
console.log(`  class-based -> ${classResult}`);
console.log(`  type object -> ${catalog.get("document").fee(s)}, types in catalog = ${catalog.size}`);

const INVALID = [
  { type: "cold-chain", base: 8640, attributes: {} },
  { type: "valuable", base: 8640, attributes: { insuredValue: 50000, temperature: -18 } },
  { type: "standard", base: 8640, attributes: { pages: 4 } },
];
let caught = 0, classCaught = 0;
for (const h of INVALID) {
  const findings = catalog.get(h.type).validate(h);
  if (findings.length > 0) caught += 1;
  let classOutput;
  try { classOutput = String(classFee(h)); } catch (e) { classOutput = `error: ${e.name}`; classCaught += 1; }
  console.log(`  ${h.type.padEnd(13)} type object: ${findings.join("; ") || "no findings"}  | class-based: ${classOutput}`);
}
console.log(`validation: type object ${caught}/${INVALID.length}, class-based ${classCaught}/${INVALID.length}`);
```

```sh
node count-types.mjs
```

```
class-based: code file carrying type name = 2 / 2, attribute list in code = 3
type object : code file carrying type name = 0 / 1, attribute list in code = 0
type data   : 3 types, type/type-data.mjs is a data file

new type "document" added (1 data row, 0 code lines)
  class-based -> error: unknown service type: document
  type object -> 6048, types in catalog = 4
  cold-chain    type object: missing attribute: temperature  | class-based: 13824
  valuable      type object: unrecognized attribute: temperature  | class-based: 11300
  standard      type object: unrecognized attribute: pages  | class-based: 8640
validation: type object 3/3, class-based 0/3
```

The first two lines give the gain: in the class-based arrangement, both of the two code files
carry the type name, and the attribute list sits in the code in three places. In the type object
arrangement, the code files carrying the type name are 0, and the attribute list in code is 0;
all three have moved into data.

The fourth type is a consequence of this. The `document` type was added without touching the
code at all: 1 data row, 0 code lines. The class-based arrangement could not price the same
shipment and raised an unknown-type error. The catalog grew to four types, and the new type's
fee was computed.

The last three lines show the cost. The class-based arrangement silently priced all three
invalid shipments: the cold chain shipment with the missing attribute came out at 13824, and the
two shipments carrying an unrecognized attribute each produced an amount as well. The type
object caught all three, but it did this at runtime, and with a validation it had to write
itself.

## Cost and When It Does Not Apply

What the type object pays is the check that exists at write time. In the class-based
arrangement, a mistyped attribute name for a type shows up in the class's text; in the type
object arrangement, attribute names turn into strings and are checked during no read at all. For
this reason, the pattern brings a validation obligation: in the measurement above, the three
findings were caught because a validation function was written, not because the pattern itself
caught them.

The second cost is that the call site becomes indistinct. In the class-based arrangement,
reading a type's fee calculation means going to that type's class; in the type object
arrangement, a single `fee` body computes for every type, and type-specific behavior is
expressed through fields like `extra`. As type-specific behavior grows more complex, the number
of these fields grows, and the type object starts turning into a small interpreter of its own.

This is where the boundary of application comes from. If the type count is fixed and small, and
every type's behavior really is a different algorithm, the pattern goes unmatched by any gain:
the attribute list already sits in 3 places, and since no new type arrives, the number of files
edited stays at 0. In that case, expressing the type through polymorphism both preserves the
write-time check and keeps the behavior in its own file.

The opposite conditions call for the pattern: if the type set arrives at runtime, if the user
defines new types, or if types differ only by data and their behaviors are parameters of a
shared rule. The criterion is one sentence: if the difference between types can be expressed
with data, type object; if it has to be expressed with code, polymorphism.

## Summary

- Type object models the type as an object rather than a class; attribute variants gather the
  fields that vary by type into a property bag and ask the type object for its validity.
- In the class-based arrangement, both of the two code files carried the type name, and the
  attribute list sat in the code in 3 places; in the type object arrangement both dropped to 0.
- The fourth type was added with 1 data row and 0 code lines, and its fee was computed; the
  class-based arrangement raised an unknown-type error for the same shipment.
- The cost is the loss of the write-time check: the class-based arrangement silently priced all
  three invalid shipments, the type object caught all three, but did so with a hand-written
  validation.
- The criterion is where the difference between types can be expressed: type object if it can
  be expressed with data, polymorphism if it has to be expressed with code.

## Course Wrap-Up

The course toured four problem classes. Creational patterns asked how and where an object gets
created: the Pattern Concept lesson built the pattern's narrative as a problem, solution, and
consequences triad, and gave the first measurement — the number of spots the type name appeared
in fell from 6 to 2, while file count rose from 3 to 5 and import edge count rose from 2 to 7.
Structural patterns asked how objects are composed and how incompatible interfaces get bridged;
the Adapter lesson counted the gain by the ratio of files touching the external dictionary, and
the cost as 3 files, 4 lines, and one level of indirection. Behavioral patterns asked how
responsibility is distributed among objects; in the Strategy lesson, adding a fourth tariff cost
2 files and 13 lines edited in the embedded version, while it was added with 0 files edited in
the strategy version, and in exchange the number of candidate implementations that must be
traced from the call site rose from 1 to 3.

Enterprise application patterns took the scale up to an application's layering. The same form of
measurement ran there too: the rule application point dropped from 3 to 1, the persistence trace
in the domain module dropped from four, two, and five steps to 0, the repeated scenario step in
the application layer dropped from 7 to 0, the number of client outputs broken when the internal
model changed dropped from 3 to 0, and the code files carrying the type name dropped from 2 to
0. In every lesson, the cost was counted with the same sentence too: files added, names added, a
level of indirection, calls that must be traced.

This is the claim set at the course's start: **a pattern is a pattern to the extent that it
improves the measure.** In none of the thirty lessons was a pattern taken as good on its own;
each was chosen because it lowered some number, and each lesson's "when it does not apply"
section showed how that same number gets reset to zero. The ratio of gain to cost depends on
context, and context can be measured: how many scenarios touch the same rule, how many clients
cross the same boundary, how many types are expected. A pattern cannot be chosen before these
questions are answered.

The patterns left one question open. All of them were about the shape of the solution: where the
rule is written, how the object is created, where the boundary is drawn. None of them asked what
the rule is. What the words shipment, tariff, zone, route, and discount mean, which of them
change together, whether the same word names two different things on two different teams — this
stayed outside the course. The next course, **Domain-Driven Design**, takes up exactly this
question: the domain language and bounded contexts becoming a modeling tool, meaning a
codebase's structure is derived from the domain's own distinctions.
