Reflection
Problem
The field that toString forgot
The order's toString is written by hand. Later, someone adds a field for the discount:
class Order { number = 1042; customer = "Maria Petrova"; total = 86.40; discount = 10; // added later
toString() { return `Order[number=${this.number}, customer=${this.customer}, total=${this.total}]`; }}
console.log(String(new Order()));Order[number=1042, customer=Maria Petrova, total=86.4]The discount isn't in the output, because nobody remembered to update toString. With ten classes, this happens all the time.
console.log(new Order()), without String, shows all the fields, discount too, even though nobody wrote code for that. How? Like this: a program can look at its own objects and classes while it runs.The idea
The class as an object
In JavaScript, a class is a function (typeof gives "function"), and its methods are in the prototype object. Looking at classes and objects while the program runs is called reflection (the program sees itself as in a mirror). There are three ways to get the class:
Order, when you know the class while you write the code;obj.constructor, when you have an object;globalThis["Map"]for a built-in class, when you only have the name as text. There's no such list for your own classes: you keep them yourself, for example in aMapfrom name to class.
const type = globalThis["Map"];console.log(`Name: ${type.name}`);console.log(`Kind: ${typeof type}`);console.log(`Parent: ${Object.getPrototypeOf(type.prototype).constructor.name}`);console.log(`Methods: ${Object.getOwnPropertyNames(type.prototype).sort().join(", ")}`);Name: MapKind: functionParent: ObjectMethods: clear, constructor, delete, entries, forEach, get, has, keys, set, size, valuesprototype above the one of Map: Object.getPrototypeOf(type.prototype). JavaScript has no interfaces. getOwnPropertyNames gives the names in the prototype, and sort() puts them in order, so the output is always the same.See it
The fields of any object
Object.entries(obj) gives the fields of the object as [name, value] pairs. typeof value tells you the kind of the value: in JavaScript, fields have no declared type.function dump(obj) { console.log(`${obj.constructor.name}:`); for (const [name, value] of Object.entries(obj)) { console.log(` ${name} (${typeof value}) = ${value}`); }}class Room { number = 214; seaView = true;}
dump(new Order());dump(new Room());Order: number (number) = 1042 customer (string) = Maria Petrova total (number) = 86.4 discount (number) = 10Room: number (number) = 214 seaView (boolean) = truediscount field is already there. The fields here are public. Fields with # are truly private: Object.entries doesn't see them, and there's no way to open them up, like setAccessible in Java.function dump(obj) { console.log(`${obj.constructor.name}:`); for (const [name, value] of Object.entries(obj)) { console.log(` ${name} (${typeof value}) = ${value}`); }}Step by step, in words
- dump gets an Order object and knows nothing about its class.
- obj.constructor is the Order class. It's an object too.
- The first line is the short name of the class.
- Object.entries(obj) gives 4 pairs: name and value.
- number comes with its value, 1042, and typeof tells you its kind: number.
- customer (string) = Maria Petrova.
- total (number) = 86.4.
- discount: the field that toString forgot shows up by itself, because reflection gets all the fields.
- The function knows nothing about Order. For Room it prints number and seaView the same way.
More precisely, if you are curious
Object.entriesandObject.keysgive only the object's own fields, without the methods (those are in theprototype).Reflect.ownKeys(obj)also gives the names of typeSymbol.- The order is the order in which the fields got a value, with one exception: names like
"1"and"2"(whole numbers) come first, sorted as numbers. - Fields with
#aren't visible to any of these methods, or toJSON.stringify.
Try it
Whose class?
What will the program print?
class Document {}
class Invoice extends Document {}
const doc = new Invoice();const proto = Object.getPrototypeOf(doc);console.log(proto.constructor.name);console.log(Object.getPrototypeOf(proto).constructor.name);console.log(Object.getPrototypeOf(Document.prototype).constructor.name);doc is Invoice.prototype, and its constructor is Invoice. Above it is Document.prototype. Document doesn't say extends, so above its prototype is Object.prototype, the common parent from the inheritance module. Above Object.prototype is null: there's nothing there.See it
Object, method and private field by name
class Product { #price;
constructor(name, price) { this.name = name; this.#price = price; }
applyDiscount(percent) { this.#price = (this.#price * (100 - percent)) / 100; }
label() { return `${this.name}: €${this.#price}`; }}const type = Product;const coffee = Reflect.construct(type, ["Coffee", 10.0]);
const discount = Reflect.get(coffee, "applyDiscount");Reflect.apply(discount, coffee, [20]);
console.log(`Fields: ${Reflect.ownKeys(coffee).join(", ")}`);console.log(`#price from outside: ${Reflect.get(coffee, "#price")}`);console.log(Reflect.get(coffee, "label").call(coffee));Fields: name#price from outside: undefinedCoffee: €8Reflect.construct(class, [arguments])makes an object, likenew.Reflect.get(coffee, "applyDiscount")gives the method, andReflect.apply(method, object, [arguments])calls it oncoffee.coffee["applyDiscount"](20)does the same.Reflect.ownKeyssees onlyname.#priceisn't a field name you can look up:Reflect.get(coffee, "#price")givesundefined. Only the methods of the class can read and change the price, so here it's8after the discount.
You don't pass the types of the parameters: in JavaScript, methods are found by name only.
More precisely, if you are curious
new Array or .push appearing in the code:const list = Reflect.construct(globalThis["Array"], []);const add = Reflect.get(list, "push");Reflect.apply(add, list, ["Coffee"]);Reflect.apply(add, list, ["Tea"]);console.log(list);[ 'Coffee', 'Tea' ]Array are fields of globalThis.Try it
The price that isn't there
TypeError on the third line. Which line has the bug?Pick the line with the bug.
The field is called name, but here it says "nmae". A missing field in JavaScript isn't an error: Reflect.get returns undefined and the program goes on. The error shows up a line later, far from the cause:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')This is the main price of reflection:
- nobody checks in advance the names and types, and the editor doesn't find them when you rename something;
- the code is slower than an ordinary call and harder to read;
-
Reflect.setgets around the encapsulation of every field without#: you can break a rule that the class protects.
console.log, JSON.stringify, ORMs like Sequelize. You rarely write it in ordinary code.The idea
Field labels
@ on fields and methods are called annotations. JavaScript has none. Decorators (@label) exist in TypeScript and are a proposal for the language itself, but Node 22 doesn't support them: @label before a method is a SyntaxError. That's why the labels here are in a static field of the class, and the function prints only the fields with a label:class Booking { static labels = { number: "Number", guest: "Guest", nights: "Nights" };
number = 501; guest = "Ivan Georgiev"; nights = 3; internalNote = "VIP";}function printLabeled(obj) { const labels = obj.constructor.labels ?? {}; for (const [name, value] of Object.entries(obj)) { if (Object.hasOwn(labels, name)) { console.log(`${labels[name]}: ${value}`); } }}Number: 501Guest: Ivan GeorgievNights: 3static labelsis a field of the class itself, not of each object. You get to it withobj.constructor.labels.Object.hasOwn(labels, name)checks whether the field has a label.internalNotedoesn't and isn't printed.- The labels are separate from the fields: if you rename a field, you have to change the name in
labelstoo.
More precisely, if you are curious
- In TypeScript, a decorator on a field is written
@label("Guest") guest = "…";. Libraries like TypeORM use exactly such decorators.
See it
How tests are found
check. The test class checks the discount from the first lesson of the module (with the old bug price > 100):class DiscountChecks { checkTenPercentOff() { this.expect(45.0, applyDiscount(50, 10)); }
checkExtraFivePercentFrom100() { this.expect(85.0, applyDiscount(100, 10)); }
expect(expected, actual) { if (expected !== actual) { throw new Error(`expected ${expected}, but was ${actual}`); } }}function runChecks(type) { const names = Object.getOwnPropertyNames(type.prototype).filter((name) => name.startsWith("check")).sort(); let passed = 0; let failed = 0; for (const name of names) { const instance = new type(); try { instance[name](); console.log(`PASS ${name}`); passed++; } catch (error) { console.log(`FAIL ${name}: ${error.message}`); failed++; } } console.log(`Checks: ${passed + failed}, passed: ${passed}, failed: ${failed}`);}FAIL checkExtraFivePercentFrom100: expected 85, but was 90PASS checkTenPercentOffChecks: 2, passed: 1, failed: 1This is how JUnit 3 (test… methods) and unittest in Python worked:
- it gets the names in the class's
prototypewithgetOwnPropertyNames, skips the ones withoutcheckin front and sorts them, so the order is always the same; - it makes a new object for each test, so the tests don't get in each other's way;
- it calls the method by name:
instance[name](). If the test throws, the error comes straight through.
node:test from the first lesson of the module works differently: there, you register a test by calling test("name", function), and no reflection is needed. assert.equal also throws an error when the values differ. Exactly like expect here.
Try it
The checks that disappeared
What will runChecks print?
class DiscountChecks { checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };}
runChecks(DiscountChecks);new, not in the class's prototype. getOwnPropertyNames(type.prototype) doesn't see it, and not one check runs. The program runs, it just doesn't do anything. That's why methods that will be looked up with reflection are written as methods: checkTenPercentOff() { … }.Exercise
Code that works with any object
Solve the tasks in your editor.
1. All the fields and the chain. WriteprintFields(obj), which first prints the chain of classes (from Object.getPrototypeOf(obj) up to null, with constructor.name), then every field as name = value. The parent's fields are already on the object and come first: the parent class puts them there before the subclass puts its own.class Vehicle { plate = "CA 1234 BK"; year = 2019;}
class Truck extends Vehicle { loadTons = 12.5;}Classes: Truck → Vehicle → Objectplate = CA 1234 BKyear = 2019loadTons = 12.5toJson(obj), which returns the fields as JSON: the names in quotes, text in quotes, numbers and true/false without quotes. Use Object.entries and typeof value === "string". 45.0 in JavaScript is just 45. (The ready-made version is JSON.stringify(obj), only without the spaces.)class Ticket { seat = 14; event = "Concert"; price = 45.0; vip = false;}{"seat": 14, "event": "Concert", "price": 45, "vip": false}{"number": 214, "seaView": true}run(type) that finds the tests by name: the methods whose names start with test, and a beforeEach method if there is one. For each test: a new object, then beforeEach, then the test. If the test throws, print the kind (error.name) and the message of the error. Try it with these tests:class Account { #balance;
constructor(balance) { this.#balance = balance; }
get balance() { return this.#balance; }
withdraw(amount) { if (amount > this.#balance) { throw new RangeError(`Short by €${amount - this.#balance}`); } this.#balance -= amount; }}
class AccountChecks { beforeEach() { this.account = new Account(100); }
testWithdraw40() { this.account.withdraw(40); if (this.account.balance !== 60) throw new Error(`expected 60, but was ${this.account.balance}`); }
testWithdraw70() { this.account.withdraw(70); if (this.account.balance !== 30) throw new Error(`expected 30, but was ${this.account.balance}`); }
testWithdrawTooMuch() { this.account.withdraw(130); }}PASS testWithdraw40PASS testWithdraw70FAIL testWithdrawTooMuch: RangeError: Short by €30Tests: 3, passed: 2, failed: 1Test