Ir al contenido
wannacode

Reflexión

En resumen: La reflexión es examinar tus propios objetos y clases mientras el programa se ejecuta: constructor, Object.entries y Reflect dan los campos y los métodos por nombre.

Problema

El campo que toString olvidó

El toString del pedido está escrito a mano. Más tarde, alguien añade un campo para el descuento:

class Order {
number = 1042;
customer = "María López";
total = 86.40;
discount = 10; // añadido después
toString() {
return `Order[number=${this.number}, customer=${this.customer}, total=${this.total}]`;
}
}
console.log(String(new Order()));
Salida
Order[number=1042, customer=María López, total=86.4]

El descuento no está en la salida, porque nadie se acordó de actualizar toString. Con diez clases, esto pasa constantemente.

Y console.log(new Order()), sin String, muestra todos los campos, también discount, aunque nadie ha escrito código para eso. ¿Cómo? Así: un programa puede examinar sus propios objetos y clases mientras se ejecuta.

La idea

La clase como objeto

En JavaScript, una clase es una función (typeof da "function"), y sus métodos están en el objeto prototype. Examinar clases y objetos mientras el programa se ejecuta se llama reflexión (en inglés, reflection: el programa se ve a sí mismo como en un espejo). Hay tres formas de obtener la clase:

  • Order, cuando conoces la clase mientras escribes el código;
  • obj.constructor, cuando tienes un objeto;
  • globalThis["Map"] para una clase incorporada, cuando solo tienes el nombre como texto. Para tus propias clases no existe una lista así: las guardas tú, por ejemplo en un Map de nombre a clase.
const type = globalThis["Map"];
console.log(`Nombre: ${type.name}`);
console.log(`Tipo: ${typeof type}`);
console.log(`Padre: ${Object.getPrototypeOf(type.prototype).constructor.name}`);
console.log(`Métodos: ${Object.getOwnPropertyNames(type.prototype).sort().join(", ")}`);
Salida
Nombre: Map
Tipo: function
Padre: Object
Métodos: clear, constructor, delete, entries, forEach, get, has, keys, set, size, values
El padre es la clase del prototype que está por encima del de Map: Object.getPrototypeOf(type.prototype). JavaScript no tiene interfaces. getOwnPropertyNames da los nombres del prototype, y sort() los ordena, para que la salida sea siempre la misma.

Míralo

Los campos de cualquier objeto

Object.entries(obj) da los campos del objeto como pares [nombre, valor]. typeof value te dice qué tipo de valor es: en JavaScript los campos no tienen un tipo declarado.
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());
Salida
Order:
number (number) = 1042
customer (string) = María López
total (number) = 86.4
discount (number) = 10
Room:
number (number) = 214
seaView (boolean) = true
Una sola función imprime cualquier objeto, y el campo nuevo discount ya está ahí. Aquí los campos son públicos. Los campos con # son realmente privados: Object.entries no los ve, y no hay forma de abrirlos, como con setAccessible en Java.

function dump(obj) {
console.log(`${obj.constructor.name}:`);
for (const [name, value] of Object.entries(obj)) {
console.log(` ${name} (${typeof value}) = ${value}`);
}
}
Paso a paso, en palabras
  1. dump recibe un objeto Order y no sabe nada de su clase.
  2. obj.constructor es la clase Order. También es un objeto.
  3. La primera línea es el nombre corto de la clase.
  4. Object.entries(obj) da 4 pares: nombre y valor.
  5. number llega con su valor, 1042, y typeof te dice qué tipo de valor es: number.
  6. customer (string) = María López.
  7. total (number) = 86.4.
  8. discount: el campo que toString olvidó aparece solo, porque la reflexión obtiene todos los campos.
  9. La función no sabe nada de Order. Para Room imprime number y seaView de la misma forma.
Más precisamente, para curiosos
  • Object.entries y Object.keys dan solo los campos propios del objeto, sin los métodos (esos están en el prototype). Reflect.ownKeys(obj) da también los nombres de tipo Symbol.
  • El orden es el orden en que los campos recibieron un valor, con una excepción: los nombres como "1" y "2" (números enteros) van primero, ordenados como números.
  • Los campos con # no los ve ninguno de estos métodos, ni tampoco JSON.stringify.

Pruébalo

¿De qué clase es?

¿Qué imprimirá el programa?

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);
Respuesta

Míralo

Objeto, método y campo privado por nombre

Con reflexión puedes crear un objeto y llamar a un método conociendo solo sus nombres. Con un campo privado no funciona:
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, ["Café", 10.0]);
const discount = Reflect.get(coffee, "applyDiscount");
Reflect.apply(discount, coffee, [20]);
console.log(`Campos: ${Reflect.ownKeys(coffee).join(", ")}`);
console.log(`#price desde fuera: ${Reflect.get(coffee, "#price")}`);
console.log(Reflect.get(coffee, "label").call(coffee));
Salida
Campos: name
#price desde fuera: undefined
Café: 8 €
  • Reflect.construct(clase, [argumentos]) crea un objeto, como new.
  • Reflect.get(coffee, "applyDiscount") da el método, y Reflect.apply(método, objeto, [argumentos]) lo llama sobre coffee. coffee["applyDiscount"](20) hace lo mismo.
  • Reflect.ownKeys ve solo name. #price no es un nombre de campo que se pueda buscar: Reflect.get(coffee, "#price") da undefined. Solo los métodos de la clase pueden leer y cambiar el precio, por eso aquí es 8 después del descuento.

No se pasan los tipos de los parámetros: en JavaScript, los métodos se encuentran solo por el nombre.

Más precisamente, para curiosos
Un array, creado y rellenado sin que en el código aparezca new Array ni .push:
const list = Reflect.construct(globalThis["Array"], []);
const add = Reflect.get(list, "push");
Reflect.apply(add, list, ["Café"]);
Reflect.apply(add, list, ["Té"]);
console.log(list);
Salida
[ 'Café', 'Té' ]
Las clases incorporadas como Array son campos de globalThis.

Pruébalo

El precio que no está

El programa arranca, pero se detiene con un TypeError en la tercera línea. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Etiquetas de campos

En Java, las etiquetas con @ en campos y métodos se llaman anotaciones. JavaScript no las tiene. Los decoradores (@label) existen en TypeScript y son una propuesta para el propio lenguaje, pero Node 22 no los admite: @label delante de un método es un SyntaxError. Por eso aquí las etiquetas están en un campo estático de la clase, y la función imprime solo los campos con etiqueta:
class Booking {
static labels = { number: "Número", guest: "Huésped", nights: "Noches" };
number = 501;
guest = "Carlos Gómez";
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}`);
}
}
}
Salida
Número: 501
Huésped: Carlos Gómez
Noches: 3
  • static labels es un campo de la propia clase, no de cada objeto. Se llega a él con obj.constructor.labels.
  • Object.hasOwn(labels, name) comprueba si el campo tiene etiqueta. internalNote no la tiene y no se imprime.
  • Las etiquetas están separadas de los campos: si cambias el nombre de un campo, tienes que cambiar también el nombre en labels.
Más precisamente, para curiosos
  • En TypeScript, un decorador en un campo se escribe @label("Huésped") guest = "…";. Bibliotecas como TypeORM usan justo decoradores así.

Míralo

Cómo se encuentran las pruebas

Aquí tienes un pequeño ejecutor de pruebas. No hay marcadores, así que las comprobaciones se encuentran por el nombre: los métodos cuyo nombre empieza por check. La clase de pruebas comprueba el descuento de la primera lección del módulo (con el viejo error 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(`esperaba ${expected}, pero es ${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(`OK ${name}`);
passed++;
} catch (error) {
console.log(`FALLA ${name}: ${error.message}`);
failed++;
}
}
console.log(`Comprobaciones: ${passed + failed}, pasan: ${passed}, fallan: ${failed}`);
}
Salida
FALLA checkExtraFivePercentFrom100: esperaba 85, pero es 90
OK checkTenPercentOff
Comprobaciones: 2, pasan: 1, fallan: 1

Así funcionaban JUnit 3 (métodos test…) y unittest en Python:

  1. obtiene los nombres del prototype de la clase con getOwnPropertyNames, se salta los que no empiezan por check y los ordena, para que el orden sea siempre el mismo;
  2. crea un objeto nuevo para cada prueba, así las pruebas no se estorban entre sí;
  3. llama al método por su nombre: instance[name](). Si la prueba lanza un error, este llega directamente.

node:test de la primera lección del módulo funciona de otra forma: allí una prueba se registra llamando a test("nombre", función), y no hace falta reflexión. assert.equal también lanza un error cuando los valores no coinciden. Igual que expect aquí.

Pruébalo

Las comprobaciones que desaparecieron

Las comprobaciones están escritas como campos con funciones flecha, y todo lo demás es igual:

¿Qué imprimirá runChecks?

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

Ejercicio

Código que funciona con cualquier objeto

Resuelve las tareas en tu editor.

1. Todos los campos y la cadena. Escribe printFields(obj), que imprima primero la cadena de clases (desde Object.getPrototypeOf(obj) hacia arriba hasta null, con constructor.name) y después cada campo como nombre = valor. Los campos del padre ya están en el objeto y van primero: la clase padre los pone antes de que la subclase ponga los suyos.
class Vehicle {
plate = "1234 BCD";
year = 2019;
}
class Truck extends Vehicle {
loadTons = 12.5;
}
Salida para new Truck()
Clases: Truck → Vehicle → Object
plate = 1234 BCD
year = 2019
loadTons = 12.5
2. Mini JSON. Escribe toJson(obj), que devuelva los campos como JSON: los nombres entre comillas, el texto entre comillas, los números y true/false sin comillas. Usa Object.entries y typeof value === "string". 45.0 en JavaScript es simplemente 45. (La versión ya hecha es JSON.stringify(obj), solo que sin los espacios).
class Ticket {
seat = 14;
event = "Concierto";
price = 45.0;
vip = false;
}
Salida para new Ticket() y new Room()
{"seat": 14, "event": "Concierto", "price": 45, "vip": false}
{"number": 214, "seaView": true}
3. Tu propio ejecutor de pruebas. Crea un ejecutor run(type) que encuentre las pruebas por el nombre: los métodos cuyo nombre empieza por test, y un método beforeEach, si lo hay. Para cada prueba: un objeto nuevo, luego beforeEach y luego la prueba. Si la prueba lanza un error, imprime el tipo (error.name) y el mensaje del error. Pruébalo con estas pruebas:
class Account {
#balance;
constructor(balance) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
withdraw(amount) {
if (amount > this.#balance) {
throw new RangeError(`Faltan ${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(`esperaba 60, pero es ${this.account.balance}`);
}
testWithdraw70() {
this.account.withdraw(70);
if (this.account.balance !== 30) throw new Error(`esperaba 30, pero es ${this.account.balance}`);
}
testWithdrawTooMuch() {
this.account.withdraw(130);
}
}
Salida
OK testWithdraw40
OK testWithdraw70
FALLA testWithdrawTooMuch: RangeError: Faltan 30 €
Tests: 3, pasan: 2, fallan: 1

Test

Ponte a prueba

1. ¿Qué te da obj.constructor?
2. ¿Puede la reflexión leer un campo #price?
3. En Reflect.get(tea, "nmae") hay una errata. ¿Qué pasa?
4. ¿Por qué no se encuentran las comprobaciones escritas como checkX = () => { … }?
5. En Node 22, ¿puedes poner una anotación con @ en un método, como en Java?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·