Skip to content
wannacode

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()));
Output
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.

And 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 a Map from 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(", ")}`);
Output
Name: Map
Kind: function
Parent: Object
Methods: clear, constructor, delete, entries, forEach, get, has, keys, set, size, values
The parent is the class of the prototype 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());
Output
Order:
number (number) = 1042
customer (string) = Maria Petrova
total (number) = 86.4
discount (number) = 10
Room:
number (number) = 214
seaView (boolean) = true
One function prints any object, and the new discount 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
  1. dump gets an Order object and knows nothing about its class.
  2. obj.constructor is the Order class. It's an object too.
  3. The first line is the short name of the class.
  4. Object.entries(obj) gives 4 pairs: name and value.
  5. number comes with its value, 1042, and typeof tells you its kind: number.
  6. customer (string) = Maria Petrova.
  7. total (number) = 86.4.
  8. discount: the field that toString forgot shows up by itself, because reflection gets all the fields.
  9. The function knows nothing about Order. For Room it prints number and seaView the same way.
More precisely, if you are curious
  • Object.entries and Object.keys give only the object's own fields, without the methods (those are in the prototype). Reflect.ownKeys(obj) also gives the names of type Symbol.
  • 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 to JSON.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);
Answer

See it

Object, method and private field by name

Through reflection you can create an object and call a method, knowing only their names. With a private field, it doesn't work:
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));
Output
Fields: name
#price from outside: undefined
Coffee: €8
  • Reflect.construct(class, [arguments]) makes an object, like new.
  • Reflect.get(coffee, "applyDiscount") gives the method, and Reflect.apply(method, object, [arguments]) calls it on coffee. coffee["applyDiscount"](20) does the same.
  • Reflect.ownKeys sees only name. #price isn't a field name you can look up: Reflect.get(coffee, "#price") gives undefined. Only the methods of the class can read and change the price, so here it's 8 after 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
An array, created and filled without 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);
Output
[ 'Coffee', 'Tea' ]
Built-in classes like Array are fields of globalThis.

Try it

The price that isn't there

The program starts, but it stops with a TypeError on the third line. Which line has the bug?

Pick the line with the bug.

The idea

Field labels

In Java, labels with @ 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}`);
}
}
}
Output
Number: 501
Guest: Ivan Georgiev
Nights: 3
  • static labels is a field of the class itself, not of each object. You get to it with obj.constructor.labels.
  • Object.hasOwn(labels, name) checks whether the field has a label. internalNote doesn't and isn't printed.
  • The labels are separate from the fields: if you rename a field, you have to change the name in labels too.
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

Here's a tiny test runner. There are no markers, so the checks are found by name: the methods whose names start with 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}`);
}
Output
FAIL checkExtraFivePercentFrom100: expected 85, but was 90
PASS checkTenPercentOff
Checks: 2, passed: 1, failed: 1

This is how JUnit 3 (test… methods) and unittest in Python worked:

  1. it gets the names in the class's prototype with getOwnPropertyNames, skips the ones without check in front and sorts them, so the order is always the same;
  2. it makes a new object for each test, so the tests don't get in each other's way;
  3. 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

The checks are written as fields with arrow functions, and everything else is the same:

What will runChecks print?

class DiscountChecks {
checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };
}
runChecks(DiscountChecks);
Answer

Exercise

Code that works with any object

Solve the tasks in your editor.

1. All the fields and the chain. Write printFields(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;
}
Output for new Truck()
Classes: Truck → Vehicle → Object
plate = CA 1234 BK
year = 2019
loadTons = 12.5
2. Mini JSON. Write toJson(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;
}
Output for new Ticket() and new Room()
{"seat": 14, "event": "Concert", "price": 45, "vip": false}
{"number": 214, "seaView": true}
3. Your own test runner. Make a runner 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);
}
}
Output
PASS testWithdraw40
PASS testWithdraw70
FAIL testWithdrawTooMuch: RangeError: Short by €30
Tests: 3, passed: 2, failed: 1

Test

Check yourself

1. What does obj.constructor give you?
2. Can reflection read a #price field?
3. There is a typo in Reflect.get(tea, "nmae"). What happens?
4. Why aren't the checks written as checkX = () => { … } found?
5. In Node 22, can you put an annotation with @ on a method, as in Java?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·