Reimplement the browser's Element.getElementsByTagName from scratch. Given a root element and a tagName, walk its subtree and return every descendant element whose tag matches — in document order. It's a classic interview question because it forces you to traverse a tree, handle case-insensitive tag matching, and remember the rules the real API follows (the root is never part of the result; '*' matches everything).
function getElementsByTagName(root: Element, tagName: string): Element[];
Returns a plain array (not a live HTMLCollection). tagName matching is case-insensitive; '*' matches every element.
const root = document.createElement('div');
root.innerHTML = '<p>a</p><div><p>b</p></div>';
getElementsByTagName(root, 'p');
// → [<p>a</p>, <p>b</p>] (both, including the nested one, in order)
getElementsByTagName(root, '*'); // every descendant element
getElementsByTagName(root, 'video'); // [] (no match)
root itself is never included, even if its tag matches the query.getElementsByTagName(root, 'div') and 'DIV' behave identically. '*' is a wildcard that matches all elements.You'll walk the root's subtree depth-first, collecting every element whose tag matches the query — normalizing case so a lower-case query still matches the browser's upper-case tag names.
The DOM is a tree: an element has child elements, which have their own children, and so on. getElementsByTagName has to visit every element beneath the root, not just the ones directly inside it, and keep the ones whose tag matches. Three rules from the real API shape the work: the root itself is never returned (only its descendants), the results come back in document order (top to bottom as written), and the match ignores case — plus '*' matches everything. The whole puzzle is a tree traversal with a filter.
Visit the tree depth-first in pre-order: handle a node, then recurse into its children before moving to its siblings. Pre-order is exactly document order, so if you push matches as you visit, the result array is already correctly ordered. Start the walk at the root's children (not the root itself), and at each element check whether its tag matches before descending further.
The obvious version loops over the root's children and filters:
function getElementsByTagName(root, tagName) {
const result = [];
for (const child of root.children) {
if (child.tagName === tagName.toUpperCase()) result.push(child);
}
return result;
}
This only looks one level deep. root.children is just the direct children, so a <span> nested inside a <div> inside a <section> is never seen — searching for 'span' returns []. The fix is to recurse: at every element, after checking it, descend into its children too, all the way down.
function getElementsByTagName(root, tagName) {
const result = [];
const matchAll = tagName === '*';
// An element's tagName is ALWAYS upper-case for HTML, so normalize the query
// once up front and compare against that.
const target = tagName.toUpperCase();
function walk(node) {
// `children` is the element children only — text and comment nodes are
// skipped automatically, so we never see a node without a tagName.
for (const child of node.children) {
if (matchAll || child.tagName === target) {
result.push(child);
}
walk(child); // descend AFTER recording, keeping pre-order / document order
}
}
walk(root); // start at root's children — the root itself is never a candidate
return result;
}
module.exports = { getElementsByTagName };
Two shifts make it correct. First, walk is recursive: it records a match and then immediately descends into that element's children, so the entire subtree is searched in pre-order. Second, the comparison normalizes case — tagName.toUpperCase() — because child.tagName comes back upper-cased ('P', 'DIV') no matter how the markup was written. Starting walk(root) from the root's children means the root itself is never tested, satisfying the descendants-only rule, and using .children (not .childNodes) skips text and comment nodes for free.
Take getElementsByTagName(root, 'p') on <p>1</p><div><p>2</p><div><p>3</p></div></div>:
walk(root) iterates the root's children: the first <p> and the outer <div>.<p> (text "1"). tagName is 'P', equal to target 'P' — push it. Recurse into it; it has no element children, so nothing more.<div>. 'DIV' !== 'P', so it's not pushed — but we still walk into it.<p> ("2") matches and is pushed; then the inner <div> doesn't match but we recurse into it; its <p> ("3") matches and is pushed.result = [<p>1</p>, <p>2</p>, <p>3</p>] — every match, in document order.Had we only looped root.children, we'd have returned just [<p>1</p>] and missed the two nested ones.
root.children (or a single loop) stops one level deep and misses nested matches. Fix: recurse — after checking an element, walk into its children too.child.tagName is upper-case, so child.tagName === 'p' is always false. Fix: normalize with tagName.toUpperCase() (or lower-case both sides) before comparing.root itself breaks the descendants-only contract. Fix: begin the walk at the root's children, never matching the root node.childNodes instead of children. childNodes includes text and comment nodes, which have no tagName (it's undefined), so the comparison silently never matches them — and '*' would wrongly try to collect them. Fix: iterate children, which is elements only.querySelectorAll territory. Generalizing from a single tag to full CSS selectors (div > p.active) is the leap from this exercise to a real selector engine — matching combinators and compound selectors against each candidate.getElementsByTagName returns a live HTMLCollection that updates as the DOM changes. Reproducing that means returning a proxy that re-runs the query on access rather than a static snapshot.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Reimplement the browser's Element.getElementsByTagName from scratch. Given a root element and a tagName, walk its subtree and return every descendant element whose tag matches — in document order. It's a classic interview question because it forces you to traverse a tree, handle case-insensitive tag matching, and remember the rules the real API follows (the root is never part of the result; '*' matches everything).
function getElementsByTagName(root: Element, tagName: string): Element[];
Returns a plain array (not a live HTMLCollection). tagName matching is case-insensitive; '*' matches every element.
const root = document.createElement('div');
root.innerHTML = '<p>a</p><div><p>b</p></div>';
getElementsByTagName(root, 'p');
// → [<p>a</p>, <p>b</p>] (both, including the nested one, in order)
getElementsByTagName(root, '*'); // every descendant element
getElementsByTagName(root, 'video'); // [] (no match)
root itself is never included, even if its tag matches the query.getElementsByTagName(root, 'div') and 'DIV' behave identically. '*' is a wildcard that matches all elements.You'll walk the root's subtree depth-first, collecting every element whose tag matches the query — normalizing case so a lower-case query still matches the browser's upper-case tag names.
The DOM is a tree: an element has child elements, which have their own children, and so on. getElementsByTagName has to visit every element beneath the root, not just the ones directly inside it, and keep the ones whose tag matches. Three rules from the real API shape the work: the root itself is never returned (only its descendants), the results come back in document order (top to bottom as written), and the match ignores case — plus '*' matches everything. The whole puzzle is a tree traversal with a filter.
Visit the tree depth-first in pre-order: handle a node, then recurse into its children before moving to its siblings. Pre-order is exactly document order, so if you push matches as you visit, the result array is already correctly ordered. Start the walk at the root's children (not the root itself), and at each element check whether its tag matches before descending further.
The obvious version loops over the root's children and filters:
function getElementsByTagName(root, tagName) {
const result = [];
for (const child of root.children) {
if (child.tagName === tagName.toUpperCase()) result.push(child);
}
return result;
}
This only looks one level deep. root.children is just the direct children, so a <span> nested inside a <div> inside a <section> is never seen — searching for 'span' returns []. The fix is to recurse: at every element, after checking it, descend into its children too, all the way down.
function getElementsByTagName(root, tagName) {
const result = [];
const matchAll = tagName === '*';
// An element's tagName is ALWAYS upper-case for HTML, so normalize the query
// once up front and compare against that.
const target = tagName.toUpperCase();
function walk(node) {
// `children` is the element children only — text and comment nodes are
// skipped automatically, so we never see a node without a tagName.
for (const child of node.children) {
if (matchAll || child.tagName === target) {
result.push(child);
}
walk(child); // descend AFTER recording, keeping pre-order / document order
}
}
walk(root); // start at root's children — the root itself is never a candidate
return result;
}
module.exports = { getElementsByTagName };
Two shifts make it correct. First, walk is recursive: it records a match and then immediately descends into that element's children, so the entire subtree is searched in pre-order. Second, the comparison normalizes case — tagName.toUpperCase() — because child.tagName comes back upper-cased ('P', 'DIV') no matter how the markup was written. Starting walk(root) from the root's children means the root itself is never tested, satisfying the descendants-only rule, and using .children (not .childNodes) skips text and comment nodes for free.
Take getElementsByTagName(root, 'p') on <p>1</p><div><p>2</p><div><p>3</p></div></div>:
walk(root) iterates the root's children: the first <p> and the outer <div>.<p> (text "1"). tagName is 'P', equal to target 'P' — push it. Recurse into it; it has no element children, so nothing more.<div>. 'DIV' !== 'P', so it's not pushed — but we still walk into it.<p> ("2") matches and is pushed; then the inner <div> doesn't match but we recurse into it; its <p> ("3") matches and is pushed.result = [<p>1</p>, <p>2</p>, <p>3</p>] — every match, in document order.Had we only looped root.children, we'd have returned just [<p>1</p>] and missed the two nested ones.
root.children (or a single loop) stops one level deep and misses nested matches. Fix: recurse — after checking an element, walk into its children too.child.tagName is upper-case, so child.tagName === 'p' is always false. Fix: normalize with tagName.toUpperCase() (or lower-case both sides) before comparing.root itself breaks the descendants-only contract. Fix: begin the walk at the root's children, never matching the root node.childNodes instead of children. childNodes includes text and comment nodes, which have no tagName (it's undefined), so the comparison silently never matches them — and '*' would wrongly try to collect them. Fix: iterate children, which is elements only.querySelectorAll territory. Generalizing from a single tag to full CSS selectors (div > p.active) is the leap from this exercise to a real selector engine — matching combinators and compound selectors against each candidate.getElementsByTagName returns a live HTMLCollection that updates as the DOM changes. Reproducing that means returning a proxy that re-runs the query on access rather than a static snapshot.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.