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()));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.
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 unMapde 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(", ")}`);Nombre: MapTipo: functionPadre: ObjectMétodos: clear, constructor, delete, entries, forEach, get, has, keys, set, size, valuesprototype 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());Order: number (number) = 1042 customer (string) = María López total (number) = 86.4 discount (number) = 10Room: number (number) = 214 seaView (boolean) = truediscount 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
- dump recibe un objeto Order y no sabe nada de su clase.
- obj.constructor es la clase Order. También es un objeto.
- La primera línea es el nombre corto de la clase.
- Object.entries(obj) da 4 pares: nombre y valor.
- number llega con su valor, 1042, y typeof te dice qué tipo de valor es: number.
- customer (string) = María López.
- total (number) = 86.4.
- discount: el campo que toString olvidó aparece solo, porque la reflexión obtiene todos los campos.
- La función no sabe nada de Order. Para Room imprime number y seaView de la misma forma.
Más precisamente, para curiosos
Object.entriesyObject.keysdan solo los campos propios del objeto, sin los métodos (esos están en elprototype).Reflect.ownKeys(obj)da también los nombres de tipoSymbol.- 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 tampocoJSON.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);doc es Invoice.prototype, y su constructor es Invoice. Por encima está Document.prototype. Document no pone extends, así que por encima de su prototype está Object.prototype, el padre común del módulo de herencia. Por encima de Object.prototype está null: ahí no hay nada.Míralo
Objeto, método y campo privado por nombre
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));Campos: name#price desde fuera: undefinedCafé: 8 €Reflect.construct(clase, [argumentos])crea un objeto, comonew.Reflect.get(coffee, "applyDiscount")da el método, yReflect.apply(método, objeto, [argumentos])lo llama sobrecoffee.coffee["applyDiscount"](20)hace lo mismo.Reflect.ownKeysve soloname.#priceno es un nombre de campo que se pueda buscar:Reflect.get(coffee, "#price")daundefined. Solo los métodos de la clase pueden leer y cambiar el precio, por eso aquí es8despué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
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);[ 'Café', 'Té' ]Array son campos de globalThis.Pruébalo
El precio que no está
TypeError en la tercera línea. ¿En qué línea está el error?Elige la línea con el error.
El campo se llama name, pero aquí pone "nmae". En JavaScript, un campo que no existe no es un error: Reflect.get devuelve undefined y el programa sigue. El error aparece una línea después, lejos de la causa:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')Este es el precio principal de la reflexión:
- nadie comprueba de antemano los nombres ni los tipos, y el editor no los encuentra cuando cambias un nombre;
- el código es más lento que una llamada normal y más difícil de leer;
-
Reflect.setse salta la encapsulación de todo campo sin#: puedes romper una regla que la clase protege.
console.log, JSON.stringify, ORM como Sequelize. En el código normal se escribe poco.La idea
Etiquetas de campos
@ 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}`); } }}Número: 501Huésped: Carlos GómezNoches: 3static labelses un campo de la propia clase, no de cada objeto. Se llega a él conobj.constructor.labels.Object.hasOwn(labels, name)comprueba si el campo tiene etiqueta.internalNoteno 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
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}`);}FALLA checkExtraFivePercentFrom100: esperaba 85, pero es 90OK checkTenPercentOffComprobaciones: 2, pasan: 1, fallan: 1Así funcionaban JUnit 3 (métodos test…) y unittest en Python:
- obtiene los nombres del
prototypede la clase congetOwnPropertyNames, se salta los que no empiezan porchecky los ordena, para que el orden sea siempre el mismo; - crea un objeto nuevo para cada prueba, así las pruebas no se estorban entre sí;
- 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
¿Qué imprimirá runChecks?
class DiscountChecks { checkTenPercentOff = () => { ... };
checkExtraFivePercentFrom100 = () => { ... };}
runChecks(DiscountChecks);new en cada objeto, no en el prototype de la clase. getOwnPropertyNames(type.prototype) no lo ve, y no se ejecuta ni una comprobación. El programa funciona, simplemente no hace nada. Por eso los métodos que se van a buscar con reflexión se escriben como métodos: checkTenPercentOff() { … }.Ejercicio
Código que funciona con cualquier objeto
Resuelve las tareas en tu editor.
1. Todos los campos y la cadena. EscribeprintFields(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;}Clases: Truck → Vehicle → Objectplate = 1234 BCDyear = 2019loadTons = 12.5toJson(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;}{"seat": 14, "event": "Concierto", "price": 45, "vip": false}{"number": 214, "seaView": true}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); }}OK testWithdraw40OK testWithdraw70FALLA testWithdrawTooMuch: RangeError: Faltan 30 €Tests: 3, pasan: 2, fallan: 1Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Arrays: push, splice y sort · Clases anidadas y datos · Funciones flecha · Tests con node:test
- Ejercicios sobre el tema: Programas en el mundo real