Implement a Model class in the style of Backbone.js — an object that stores a bag of attributes and lets other code subscribe to changes. When an attribute's value changes, the model notifies its subscribers; when nothing changed, it stays silent. This is the observer pattern at the heart of classic MVC front ends: a view registers a listener on the model, and re-renders itself whenever the data it displays actually changes — without the model needing to know the view exists.
class Model {
constructor(attrs?: Record<string, unknown>); // no change events fire here
get(key: string): unknown; // value, or undefined if absent
set(key: string, value: unknown, options?: { silent?: boolean }): this;
set(attrs: Record<string, unknown>, options?: { silent?: boolean }): this;
on(event: string, callback: Function): void; // subscribe
off(event?: string, callback?: Function): void; // unsubscribe
trigger(event: string, ...args: unknown[]): void; // fire manually
previous(key: string): unknown; // value before the last change
changedAttributes(): Record<string, unknown> | false; // keys changed in the last set
toJSON(): Record<string, unknown>; // shallow copy of attributes
}
For each key whose value actually changes, set fires a change:<key> event with (model, newValue). After all per-key events, it fires one general change event with (model). A change:<key> or change event fires only when at least one value changed. set returns this so calls chain.
const m = new Model({ name: 'Ada' });
m.on('change:name', (model, value) => console.log('name ->', value));
m.on('change', (model) => console.log('something changed'));
m.set('name', 'Grace');
// logs: "name -> Grace"
// logs: "something changed"
// Setting a key to the value it already holds fires NOTHING.
const m = new Model({ count: 0 });
m.on('change', () => console.log('changed'));
m.set('count', 0); // (no output — 0 === 0)
m.set('count', 1); // logs: "changed"
// The object form sets many keys but fires the general change only once.
const m = new Model({ a: 1, b: 2 });
m.on('change:a', () => console.log('a'));
m.on('change:b', () => console.log('b'));
m.on('change', () => console.log('general'));
m.set({ a: 10, b: 20 });
// logs: "a", "b", "general" — one general event, not two
// Silent set updates state but stays quiet.
m.set({ a: 99 }, { silent: true }); // (no output); m.get('a') === 99
attrs to new Model(attrs) seeds the values directly. It must not fire any change event — there are no listeners yet, and seeding is not a change.===). A key "changed" only when its new value is not === to its current value. set('count', 0) when count is already 0 fires nothing. Object identity counts: setting a key to a different object with the same contents does fire, because the references differ.change:<key> listener that calls model.get(key) must read the new value, not the old one. Apply the change to the internal store first, then invoke listeners.change. Each changed key gets its own change:<key>, but the whole set produces exactly one general change, regardless of how many keys changed.off has three modes. off(event, cb) removes one listener; off(event) removes every listener for that event; off() removes everything.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.