Classic utilities
Build production-grade deepClone, deepEqual, curry, compose, memoize, once, flattenObject, unflatten, and get utilities with edge-case tests.
- 01Turn interview prompts into contractsWrite requirements and conformance checks before hardening
deepClone,deepEqual,curry,memoize, and path helpers. - 02Handle the sharp edges deliberatelySupport Dates, RegExps, Maps, Sets, cycles, placeholders, cache clearing, empty containers, and default path values without guessing.
- 03Choose built-ins or libraries honestlyCompare custom utilities with
structuredClone, JSON round trips, spread, Lodash, and Ramda so production code stays boring.
Utilities are tiny contracts
A classic utility is a small reusable function with a surprisingly strict promise: clone this graph, compare these values, collect these arguments, cache this result, run this callback once, or turn nested paths into strings and back.
You already met the ideas in advanced recursion, currying, composition, memoization, closures, WeakMap and WeakSet, messaging and cloning, Object.keys, values, entries, and the upcoming polyfill array methods lesson. Here you use them like an interview and utility-library capstone: write the contract, build a naive version, prove the failure, then harden the function.
Classic utilities are focused helper functions whose behavior is defined by edge-case requirements and conformance tests, not by a vague name like deep or smart.
Picture a shared toolbox. The label helps only if the safety card says what the tool can cut, what it must not touch, and how to inspect the result. A utility function deserves the same treatment.
- In real life: Tool label
- In JavaScript: Function name such as
deepClone - In real life: Safety card
- In JavaScript: Requirements and edge-case table
- In real life: Right tool for the job
- In JavaScript: Built-in, library, or custom helper
- In real life: Inspection stamp
- In JavaScript: Conformance tests that run real JavaScript
Where the analogy stops: A physical tool does not share nested references, close over variables, or depend on engine-specific clone rules. JavaScript utilities must state those policies explicitly.
| Case | deepClone | structuredClone | JSON round-trip | Spread |
|---|---|---|---|---|
| Nested plain data | Deep copy | Deep copy | Deep copy if JSON-safe | Nested references stay shared |
| Date | Keeps a Date | Keeps a Date | Becomes an ISO string | Same Date reference |
| RegExp | Keeps source, flags, and lastIndex | Keeps source and flags, resets some state by spec | Becomes {} | Same RegExp reference |
| Map and Set | Deep copies keys and values | Keeps Map and Set data | Becomes {} or arrays you prepared yourself | Same collection reference |
| Cycle | WeakMap rewires to the clone | Supported | Throws TypeError | Only the top level is copied |
| Function | Returned as the same callable | Throws DataCloneError | Dropped or omitted | Same function reference |
| Class instance | Prototype kept by default, optional plain object | Data copied as a plain object | Plain JSON data only | Prototype methods are not copied by spread |
Clone and compare data
STEP THROUGHThe naive deep-clone interview answer usually handles arrays and plain objects. It fails the moment a graph points back to itself, and it silently loses Dates, RegExps, Maps, Sets, symbols, and prototypes. Mark the failure as deliberate, then fix it.
| Requirement | Policy |
|---|---|
| Primitive | Return the same value; primitives are already immutable enough. |
| Arrays and plain objects | Allocate fresh containers and recursively copy their values. |
| Date | Create new Date(value.getTime()) so identity changes but time stays. |
| RegExp | Copy source, flags, and lastIndex when state matters. |
| Map and Set | Allocate empty collections, register them in seen, then clone entries. |
| Cycle | Store source-to-copy pairs in a WeakMap before descending. |
| Prototype policy | Preserve prototypes by default, but allow plain-object output when callers ask. |
function cloneJsonStyle(value) { if (value === null || typeof value !== "object") return value; if (Array.isArray(value)) return value.map(cloneJsonStyle); const copy = {}; for (const key of Object.keys(value)) { copy[key] = cloneJsonStyle(value[key]); } return copy;} const profile = { name: "Ada" };profile.self = profile;console.log(cloneJsonStyle(profile));Line 4 recurses into every property. Line 12 creates the edge case. Line 13 tries to clone the same object again and again until the stack overflows. A production version registers each source object in a WeakMap before walking its children.
Step through a real deep clone with a custom prototype, RegExp state, Map, Set, and a cycle. The WeakMap step is the difference between a utility and a demo.
script
if (value === null || (typeof value !== "object" && typeof value !== "function")) return value; if (typeof value === "function") return value; if (seen.has(value)) return seen.get(value); if (value instanceof Date) return new Date(value.getTime()); if (value instanceof RegExp) { const copy = new RegExp(value.source, value.flags); copy.lastIndex = value.lastIndex; return copy; } if (value instanceof Map) { const copy = new Map(); seen.set(value, copy); for (const [key, entry] of value) { copy.set(deepClone(key, options, seen), deepClone(entry, options, seen)); } return copy; } if (value instanceof Set) { const copy = new Set(); seen.set(value, copy); for (const entry of value) copy.add(deepClone(entry, options, seen)); return copy; } const copy = Array.isArray(value) ? [] : options.preservePrototype === false ? {} : Object.create(Object.getPrototypeOf(value)); seen.set(value, copy); for (const key of Reflect.ownKeys(value)) { copy[key] = deepClone(value[key], options, seen); } return copy;} class Profile { constructor(name) { this.name = name; } greet() { return "Hi " + this.name; }}const original = new Profile("Ada");original.pattern = /clone/gi;original.pattern.lastIndex = 2;original.visits = new Map([[{ day: "Mon" }, new Set([1, 2])]]);original.self = original; const copy = deepClone(original);console.log(copy !== original);console.log(copy instanceof Profile);console.log(copy.pattern.lastIndex);console.log([...copy.visits.values()][0].has(2));console.log(copy.self === copy);const value = makeValue("date");console.log("deepClone:", summarize(deepClone(value)));try { console.log("structuredClone:", summarize(structuredClone(value))); }catch (error) { console.log("structuredClone:", error.name); }try { console.log("JSON:", summarize(JSON.parse(JSON.stringify(value)))); }catch (error) { console.log("JSON:", error.name); }console.log("spread:", summarize({ ...value }));Date:2024-01-02Date:2024-01-02{"when":"2024-01-02T00:00:00.000Z"}Date:2024-01-02The selected input is Date. Compare what each strategy keeps, loses, or rejects.
structuredClone should be your first stop when it matches the problem: it is built into modern browsers and Node, supports cycles, and understands many platform data types. Write your own clone only when you need a different policy, such as preserving a custom prototype or returning functions by reference.
| Case | Policy |
|---|---|
NaN | Equal, because Object.is(NaN, NaN) is true. |
+0 and -0 | Different in this lesson's policy; switch to === if your product treats them the same. |
| Arrays | Both sides must be arrays with the same length and same ordered values. |
| Objects | Prototypes and own-key counts must match before values recurse. |
| Dates and RegExps | Compare their internal values, not object identity. |
| Maps and Sets | Compare size and match entries by deep equality, not by object identity alone. |
| Cycles | Remember object pairs so equal circular graphs terminate. |
function deepEqual(a, b, seen = new WeakMap()) { if (Object.is(a, b)) return true; if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; const known = seen.get(a); if (known?.has(b)) return true; if (!known) seen.set(a, new WeakSet([b])); else known.add(b); if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false; if (a instanceof Date || b instanceof Date) return a instanceof Date && b instanceof Date && Object.is(a.getTime(), b.getTime()); if (a instanceof RegExp || b instanceof RegExp) return a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags && a.lastIndex === b.lastIndex; if (Array.isArray(a) || Array.isArray(b)) { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => deepEqual(item, b[index], seen)); } if (a instanceof Map || b instanceof Map) { if (!(a instanceof Map) || !(b instanceof Map) || a.size !== b.size) return false; const unmatched = [...b.entries()]; return [...a.entries()].every(([key, value]) => { const index = unmatched.findIndex(([otherKey, otherValue]) => deepEqual(key, otherKey, seen) && deepEqual(value, otherValue, seen)); if (index === -1) return false; unmatched.splice(index, 1); return true; }); } if (a instanceof Set || b instanceof Set) { if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size) return false; const unmatched = [...b.values()]; return [...a.values()].every((value) => { const index = unmatched.findIndex((other) => deepEqual(value, other, seen)); if (index === -1) return false; unmatched.splice(index, 1); return true; }); } const aKeys = Reflect.ownKeys(a); const bKeys = Reflect.ownKeys(b); if (aKeys.length !== bKeys.length) return false; const bKeySet = new Set(bKeys); return aKeys.every((key) => bKeySet.has(key) && deepEqual(a[key], b[key], seen));} const left = { n: NaN, z: -0, list: [1, { x: 2 }] };const right = { list: [1, { x: 2 }], z: 0, n: NaN };console.log(deepEqual(left, right));right.z = -0;console.log(deepEqual(left, right));left.self = left;right.self = right;console.log(deepEqual(left, right));console.log(deepEqual(new Map([[{ id: 1 }, new Set([2])]]), new Map([[{ id: 1 }, new Set([2])]])));A naive equality helper starts with a === b and immediately fails NaN. This version states the policy up front with Object.is: NaN equals itself, while +0 and -0 are different. Arrays are ordered, objects must have the same prototype and own-key count, and Maps and Sets match by deep value rather than reference.
Function reshaping utilities
GROUPED ARGSThe currying lesson already explained the mental model. The utility-library version needs a clearer contract: use fn.length as the default arity, accept grouped arguments, and optionally support placeholders. It should not re-explain why currying exists; it should make call sites predictable.
const __ = Symbol("placeholder");function curry(fn, arity = fn.length) { return function curried(...args) { const complete = args.length >= arity && args.slice(0, arity).every((arg) => arg !== __); if (complete) return fn.apply(this, args.slice(0, arity)); return function collect(...more) { const merged = args.map((arg) => arg === __ && more.length ? more.shift() : arg).concat(more); return curried.apply(this, merged); }; };} const format = (greeting, name, punctuation) => greeting + ", " + name + punctuation;const greet = curry(format);console.log(greet("Hi")("Ada")("!"));console.log(greet("Hi", "Lin")("."));console.log(greet(__, "Grace")("Hello")("!"));Line 1 creates one placeholder symbol. Line 4 decides whether the first arity slots are filled. Line 7 merges later arguments into earlier placeholders before appending extra grouped arguments. This is still fn.length-based, so default and rest parameters need an explicit arity argument.
const compose = (...fns) => (input) => fns.reduceRight((value, fn) => fn(value), input);const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input);const pipeAsync = (...fns) => (input) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(input)); const trim = (text) => text.trim();const lower = (text) => text.toLowerCase();const dash = (text) => text.replaceAll(" ", "-"); console.log(compose(dash, lower, trim)(" Utility Lesson "));console.log(pipe(trim, lower, dash)(" Utility Lesson "));pipeAsync( async (id) => ({ id, role: "admin" }), async (user) => user.role.toUpperCase(),)(7).then(console.log);A naive composition helper nests calls by hand. The fixed version turns the function list into a reducer. compose uses reduceRight because the input enters from the right; pipe uses reduce because the input flows left to right. For asynchronous work, start from Promise.resolve(input) and chain with .then(fn).
Caching and one-time work
CACHE POLICYA naive memoizer serializes arguments with JSON.stringify and hides the cache. That breaks on key order, object identity, functions, and memory cleanup. A production helper takes a resolver argument and exposes the cache so tests and invalidation can be explicit.
function memoize(fn, resolver = (...args) => JSON.stringify(args)) { const cache = new Map(); function memoized(...args) { const key = resolver(...args); if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; } memoized.cache = cache; memoized.clear = () => cache.clear(); return memoized;} function once(fn) { let called = false; let result; let error; let threw = false; return function runOnce(...args) { if (!called) { called = true; try { result = fn.apply(this, args); } catch (caught) { threw = true; error = caught; throw caught; } } if (threw) throw error; return result; };} let calls = 0;const area = memoize((w, h) => { calls++; return w * h; }, (w, h) => w + "x" + h);console.log(area(3, 4));console.log(area(3, 4));console.log(calls);area.clear();console.log(area(3, 4));console.log(calls); const button = { label: "save", init: once(function (suffix) { return this.label + suffix; }) };console.log(button.init("!"));console.log(button.init("?"));The resolver owns the key policy. Line 10 exposes clear, which is boring but essential in tests. The once helper marks the first call, runs with fn.apply(this, args), and returns the first result forever, even when later calls pass different arguments.
function createFakeClock() { let now = 0; let nextId = 1; const tasks = []; return { now: () => now, setTimeout(callback, wait) { const task = { id: nextId++, time: now + wait, callback, cleared: false }; tasks.push(task); return task.id; }, clearTimeout(id) { for (const task of tasks) if (task.id === id) task.cleared = true; }, tick(ms) { const end = now + ms; while (true) { const ready = tasks.filter((task) => !task.cleared && task.time <= end).sort((a, b) => a.time - b.time)[0]; if (!ready) break; ready.cleared = true; now = ready.time; ready.callback(); } now = end; }, };} function debounce(fn, wait, clock = { setTimeout, clearTimeout }) { let timeout; return function debounced(...args) { clock.clearTimeout(timeout); timeout = clock.setTimeout(() => fn.apply(this, args), wait); };} function throttle(fn, wait, clock = { now: Date.now, setTimeout, clearTimeout }) { let lastRun = -Infinity; let timeout; let lastArgs; let lastThis; return function throttled(...args) { const remaining = wait - (clock.now() - lastRun); lastArgs = args; lastThis = this; if (remaining <= 0) { if (timeout !== undefined) clock.clearTimeout(timeout); timeout = undefined; lastRun = clock.now(); fn.apply(lastThis, lastArgs); } else if (timeout === undefined) { timeout = clock.setTimeout(() => { timeout = undefined; lastRun = clock.now(); fn.apply(lastThis, lastArgs); }, remaining); } };} const log = [];const clock = createFakeClock();const save = debounce((value) => log.push("debounced " + value), 100, clock);save("a");clock.tick(50);save("b");clock.tick(100);const paint = throttle((value) => log.push("throttled " + value), 100, clock);paint("one");clock.tick(20);paint("two");clock.tick(100);console.log(log.join(" | "));Timer utilities are a bonus, but their tests teach the same habit. debounce waits for quiet before running the latest call. throttle runs at most once per window and keeps the latest trailing arguments. A fake clock proves both without waiting in real time.
Flatten, unflatten, and get
ROUND TRIPFlattening turns nested data into path keys. The production question is not recursion; it is policy. Are arrays written as indices? What happens to empty objects? Can the inverse rebuild the same shape? What if a real key contains a dot?
| Case | Policy |
|---|---|
| Nested objects | { a: { b: 1 } } becomes { 'a.b': 1 }. |
| Arrays | Indices become segments: list.0, list.1, and so on. |
| Empty object | Keep {} at its path so the inverse does not lose it. |
| Empty array | Keep [] at its path for the same reason. |
| Round trip | unflatten(flattenObject(value)) should rebuild JSON-shaped data. |
| Path helper | get(obj, 'a.b[0].c', fallback) returns the fallback only for missing or undefined values. |
Step through flattening a nested object into dotted paths, then rebuilding arrays and empty containers on the way back.
script
const isObject = value !== null && typeof value === "object"; if (!isObject) { if (prefix) output[prefix] = value; return output; } const entries = Array.isArray(value) ? value.map((entry, index) => [String(index), entry]) : Object.entries(value); if (entries.length === 0 && prefix) { output[prefix] = Array.isArray(value) ? [] : {}; return output; } for (const [key, entry] of entries) { const path = prefix ? prefix + "." + key : key; flattenObject(entry, path, output); } return output;} function unflatten(flat) { const root = {}; for (const [path, value] of Object.entries(flat)) { const parts = path.split("."); let cursor = root; parts.forEach((part, index) => { const last = index === parts.length - 1; if (last) cursor[part] = value; else cursor = cursor[part] ??= /^\d+$/.test(parts[index + 1]) ? [] : {}; }); } return root;} const settings = { user: { name: "Ada" }, list: ["js", "utils"], empty: {} };const flat = flattenObject(settings);console.log(flat["user.name"]);console.log(flat["list.0"]);console.log(Array.isArray(flat.empty) ? "array" : typeof flat.empty);console.log(JSON.stringify(unflatten(flat)) === JSON.stringify(settings));function get(obj, path, defaultValue) { const parts = path.replace(/\[(\w+)\]/g, ".$1").split(".").filter(Boolean); let cursor = obj; for (const part of parts) { if (cursor == null) return defaultValue; cursor = cursor[part]; } return cursor === undefined ? defaultValue : cursor;} const data = { users: [{ profile: { name: "Ada" } }] };console.log(get(data, "users[0].profile.name", "Unknown"));console.log(get(data, "users[1].profile.name", "Unknown"));The visible get implementation handles common dot and bracket paths such as users[0].profile.name. In production, arbitrary keys need an escaping policy or an array of path segments; otherwise {"a.b": 1} and {"a": {"b": 1}} look identical to the parser.
When to build or import
SORT ITReal projects do not get points for reimplementing Lodash. Build a helper when the contract is narrow and tested. Use a library when the edge-case surface is broad. Use platform features like structuredClone and Object.groupBy when they match the contract.
| Choice | Examples | When to use |
|---|---|---|
| Lodash | _.cloneDeep, _.isEqual, _.curry, _.memoize, _.once, _.debounce, _.throttle, _.get | Use it when browser support, bundle policy, and edge cases justify a proven dependency. |
| Ramda | R.curry, R.compose, R.pipe, R.memoizeWith, R.path | Use it when a codebase already embraces data-last functional style. |
| Built-ins | structuredClone, Object.groupBy, Map, Set, WeakMap, Object.entries | Use them first when they match the contract exactly. |
| Custom utility | A small function plus conformance tests | Use it when you need one policy, not a general-purpose library surface. |
- Clone plain data, Dates, Maps, Sets, and cycles with no functions or class identity.
- Compare many nested values across a product with years of edge cases.
- Memoize one expensive selector with a known string key and manual clearing.
- Flatten user keys that may already contain dots without an escaping policy.
- Make an initialization callback run only once and preserve the first result.
- A team already uses data-last helpers and wants point-free pipelines everywhere.
- Group array items by a property in modern Node and browsers.
- Deep clone live closures and DOM nodes for app state.
Sort each card by who should own the edge cases: the platform, a mature library, your code, or nobody because the shape is too ambiguous.
Common misconceptions
- “Deep clone means copy everything.” No helper can safely clone closures, DOM identity, private fields, and every descriptor unless the contract says so.
- “JSON round-trip is a clone.” It is a JSON data transform. Dates, Maps, Sets, functions,
undefined, symbols, and cycles prove the difference. - “Deep equal is obvious.”
NaN, signed zero, prototypes, Map order, Set matching, and cycles are policy choices. - “Currying should guess any function shape.”
fn.lengthignores rest parameters and stops at the first default parameter. Let callers pass arity when needed. - “Path strings are always safe.” Keys with dots or brackets need escaping or segment arrays.
| Shortcut | Fails when | Production habit |
|---|---|---|
| Spread for clone | Nested references must be independent | Use spread only for shallow updates. |
JSON.stringify(a) === JSON.stringify(b) | Key order, NaN, Dates, Maps, or cycles appear | Use an equality contract and tests. |
JSON.stringify(args) as a memo key | Objects have different key order or identity matters | Use a resolver that matches the call site. |
path.split('.') for all data | A key contains a dot | Escape segments or accept segment arrays. |
Practice exercises
5 EXERCISESonceFill in the helper, then type the three logs the completed program should print.
function once(fn) {
// TODO: remember whether fn already ran and return the first result forever
}
let calls = 0;
const init = once(() => ++calls);
console.log(init());
console.log(init());
console.log(calls);function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
let calls = 0;
const init = once(() => ++calls);
console.log(init());
console.log(init());
console.log(calls);The first call increments calls to 1 and stores that result. Later calls return 1 without running the callback again, so the counter remains 1.
NaNReplace the primitive equality check so the program prints the desired value.
function equal(a, b) {
if (a === b) return true;
return false;
}
console.log(equal(NaN, NaN));function equal(a, b) {
return Object.is(a, b);
}
console.log(equal(NaN, NaN));Object.is(NaN, NaN) is true, matching the lesson's equality policy.
unflattenImplement the inverse for simple dotted paths and type the JSON string it should print.
function unflatten(flat) {
const root = {};
// TODO: split each path and create containers
return root;
}
console.log(JSON.stringify(unflatten({ "user.name": "Ada", "list.0": "js" })));function unflatten(flat) {
const root = {};
for (const [path, value] of Object.entries(flat)) {
const parts = path.split(".");
let cursor = root;
parts.forEach((part, index) => {
const last = index === parts.length - 1;
if (last) cursor[part] = value;
else cursor = cursor[part] ??= /^\d+$/.test(parts[index + 1]) ? [] : {};
});
}
return root;
}
console.log(JSON.stringify(unflatten({ "user.name": "Ada", "list.0": "js" })));The solution walks each path, creates containers as needed, and writes the leaf value at the final segment.
get with defaultsSupport the path users[0].profile.name and return a default for missing users.
function get(obj, path, defaultValue) {
// TODO: support dots, brackets, and a default for missing values
}
const data = { users: [{ profile: { name: "Ada" } }] };
console.log(get(data, "users[0].profile.name", "Unknown"));
console.log(get(data, "users[1].profile.name", "Unknown"));function get(obj, path, defaultValue) {
const parts = path.replace(/\[(\w+)\]/g, ".$1").split(".").filter(Boolean);
let cursor = obj;
for (const part of parts) {
if (cursor == null) return defaultValue;
cursor = cursor[part];
}
return cursor === undefined ? defaultValue : cursor;
}
const data = { users: [{ profile: { name: "Ada" } }] };
console.log(get(data, "users[0].profile.name", "Unknown"));
console.log(get(data, "users[1].profile.name", "Unknown"));The path parser converts bracket notation to segments, then the loop exits safely when the second user is missing.
Assume the lesson's deepClone is in scope. Type the two values printed.
class Item { constructor(id) { this.id = id; } }
const copy = deepClone(new Item(7), { preservePrototype: false });
console.log(copy instanceof Item);
console.log(copy.id);With { preservePrototype: false }, the cloned value is not an Item, but its own id property is still copied as 7.
Check your understanding
7 QUESTIONSQuestion 1 of 7Why does the fixed
deepClonewrite to aWeakMapbefore copying child properties?Choose an answer to see the explanation.
Question 2 of 7What does this
Object.ispolicy print?Read the code, then predictconsole.log(Object.is(NaN, NaN)); console.log(Object.is(+0, -0));Choose an answer to see the explanation.
Question 3 of 7When does this curried call execute the original function?
Read the code, then predictconst __ = Symbol("placeholder"); const add3 = (a, b, c) => a + b + c; const curried = curry(add3); console.log(curried(__, 2)(1)(3));Choose an answer to see the explanation.
Question 4 of 7What does the memoized area example print after a cache hit?
Read the code, then predictlet calls = 0; const area = memoize((w, h) => { calls++; return w * h; }, (w, h) => w + "x" + h); console.log(area(3, 4)); console.log(area(3, 4)); console.log(calls);Choose an answer to see the explanation.
Question 5 of 7What does
oncepreserve in this example?Read the code, then predictconst tool = { label: "save", init: once(function (suffix) { return this.label + suffix; }) }; console.log(tool.init("!")); console.log(tool.init("?"));Choose an answer to see the explanation.
Question 6 of 7What path appears for the first array item after flattening?
Read the code, then predictconst flat = flattenObject({ list: ["js"], empty: [] }); console.log(flat["list.0"]); console.log(Array.isArray(flat.empty));Choose an answer to see the explanation.
Question 7 of 7Why is a path string API risky for arbitrary user keys?
Choose an answer to see the explanation.
Key takeaways
- Utility names are not enough; write the supported inputs, unsupported inputs, and edge-case policies.
deepCloneanddeepEqualbecome reliable only when cycles, special objects, prototypes, and equality policy are tested.curry,compose, andpipeshould keep call sites clearer, not cleverer.memoizeneeds a key resolver and visible cache controls;oncemust preserve the first receiver, arguments, and result.- Flattened paths require array, empty-container, escaping, inverse, and default-value policies.
Remember the one-liner.
Build utility functions like small libraries: contract first, naive failure second, hardened implementation third, conformance tests always.
Next in this module, build an event emitter turns callbacks into a tiny subscription system. The same discipline applies: state the behavior, build the minimal API, and prove the edge cases.