Lesson 30 / 30
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.
Contents
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.
// 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); } }
// 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.
// 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.
// 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.
// 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}`);
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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.