Implement a singly linked list — a chain of nodes where each node holds a value and a pointer to the next node. Unlike an Array, there is no contiguous block of memory; you walk the chain one .next at a time. Your list tracks both a head (first node) and a tail (last node), plus a size counter that stays accurate across every mutation.
You're building a class with five methods: append, prepend, remove, get, and find. The list is also iterable, so [...list] and for (const v of list) work.
class LinkedList<T> {
head: { value: T; next: Node | null } | null;
tail: { value: T; next: Node | null } | null;
size: number;
append(value: T): void; // add to the end
prepend(value: T): void; // add to the front
remove(value: T): boolean; // remove first occurrence; return true if removed
get(index: number): T | undefined; // value at index, or undefined if out of range
find(predicate: (v: T) => boolean): Node | null; // first matching node, or null
toArray(): T[]; // values, in order
[Symbol.iterator](): Iterator<T>; // iteration support
}
const list = new LinkedList();
list.append(1);
list.append(2);
list.append(3);
list.toArray(); // [1, 2, 3]
list.size; // 3
list.prepend(0);
list.toArray(); // [0, 1, 2, 3]
list.remove(2); // true
list.toArray(); // [0, 1, 3]
list.size; // 3
list.get(0); // 0
list.get(99); // undefined
list.find((v) => v > 0)?.value; // 1
const empty = new LinkedList();
empty.remove(42); // false — no-op on an empty list, never throws
empty.size; // 0
empty.toArray(); // []
[...empty]; // []
head, tail, and size. Every mutation updates all three correctly. append on an empty list must set both head and tail to the new node.remove(value) removes the first occurrence only. If the value appears twice, the second occurrence stays. Return true when something was removed, false otherwise.head, tail removal walks to the penultimate node and clears tail, removing a singleton empties both pointers.get(index) returns undefined for out-of-range indices (negative or >= size). Don't throw.find takes a predicate, not a value. Return the first node (not the value) whose value satisfies the predicate, or null.[Symbol.iterator] lets for…of and spread work for free.Array under the hood. Maintaining head/tail/next pointers is the whole point of the exercise.You're building a class that stitches plain objects into a chain via next pointers, with a few cached shortcuts (head, tail, size) that keep the common operations cheap.
A linked list is what you'd build if you didn't have Array. Each "node" is a tiny object holding a value and a pointer to the next node — like train carriages clipped together. To find the fifth carriage, you walk from the front, one coupling at a time. There's no arr[4] shortcut; the only API the chain exposes is "give me the next one." That sounds slow, but it buys you something Array can't: inserting a new carriage between two existing ones is just rewiring two pointers — no shifting elements.
Your job is to wrap that chain in a class that exposes the operations users expect (append, prepend, remove, get, find) while keeping three pieces of bookkeeping in sync on every mutation: the head pointer, the tail pointer, and a size counter.
A list is three things:
{ value, next }. The last node's next is null.head field pointing at the first node (or null if empty).tail field pointing at the last node (or null if empty), plus a size counter.head and tail aren't part of the chain — they're shortcuts on the list itself so append doesn't have to walk to the end every time.
Two pictures of the same fact, depending on which end you mutate:
A reasonable first cut keeps a head and writes everything in terms of walking from head. No tail, no size counter — "I'll just count when asked."
class NaiveList {
constructor() {
this.head = null;
}
append(value) {
const node = { value, next: null };
if (!this.head) {
this.head = node;
return;
}
// Walk to the end every time.
let cur = this.head;
while (cur.next) cur = cur.next;
cur.next = node;
}
get size() {
let n = 0;
let cur = this.head;
while (cur) { n++; cur = cur.next; }
return n;
}
}
This is correct in the "tests pass on a 3-element list" sense, but it has a concrete failure: append is now O(n). Build a list with a million calls to append and you've done roughly 0 + 1 + 2 + ... + 999,999 pointer hops — about 500 billion. The same workload with a cached tail does a million constant-time pointer assignments and finishes in a blink. A computed size getter has the same shape: every read walks the list. A simple console.log(list.size) in a hot loop turns O(1) reads into O(n).
Caching tail and size is the fix. They cost two pointer updates and one integer increment per mutation; in return, append is O(1) and size is a property read.
class LinkedList {
constructor() {
// Three pieces of bookkeeping that must stay in sync on every mutation.
// An empty list has both head and tail null; non-empty has both non-null.
this.head = null;
this.tail = null;
this.size = 0;
}
append(value) {
const node = { value, next: null };
if (this.tail === null) {
// Empty list: the new node is BOTH head and tail. Forgetting to set
// head here is the classic append-on-empty bug.
this.head = node;
this.tail = node;
} else {
// Non-empty: hook the new node onto the old tail, then advance tail.
// Order matters: write tail.next first, then reassign tail.
this.tail.next = node;
this.tail = node;
}
this.size++;
}
prepend(value) {
// The new node's next is whatever was previously the head (possibly null).
// Then head moves to the new node. tail only changes if the list was empty.
const node = { value, next: this.head };
this.head = node;
if (this.tail === null) this.tail = node;
this.size++;
}
remove(value) {
// Empty list: nothing to do. Return false so callers can branch on it.
if (this.head === null) return false;
// Case 1: removing the head. No walking; just bump head forward.
if (this.head.value === value) {
this.head = this.head.next;
// If that was the only node, the list is now empty — clear tail too.
if (this.head === null) this.tail = null;
this.size--;
return true;
}
// Case 2: walk until we find a node whose NEXT holds the value. We need
// a handle on the previous node to rewire its `next` past the doomed one.
let prev = this.head;
while (prev.next !== null) {
if (prev.next.value === value) {
// Rewire to skip past the target. If the target was the tail,
// prev becomes the new tail and we must update the cache.
prev.next = prev.next.next;
if (prev.next === null) this.tail = prev;
this.size--;
return true;
}
prev = prev.next;
}
// Walked to the end without finding the value.
return false;
}
get(index) {
// Out-of-range (negative or past the end) returns undefined — don't throw.
if (index < 0 || index >= this.size) return undefined;
let cur = this.head;
for (let i = 0; i < index; i++) cur = cur.next;
return cur.value;
}
find(predicate) {
// Returns the first NODE (not the value) whose value matches.
// Returning the node lets the caller inspect `.next`, which is useful
// for in-place mutations the public API doesn't expose.
let cur = this.head;
while (cur !== null) {
if (predicate(cur.value)) return cur;
cur = cur.next;
}
return null;
}
toArray() {
const out = [];
for (let cur = this.head; cur !== null; cur = cur.next) {
out.push(cur.value);
}
return out;
}
*[Symbol.iterator]() {
// A generator is the shortest correct iterator. Each `yield` suspends
// until the consumer asks for the next value, so `for...of` and spread
// work without manually building { next, done } objects.
for (let cur = this.head; cur !== null; cur = cur.next) {
yield cur.value;
}
}
}
module.exports = { LinkedList };
The shifts from the naive version are small but load-bearing. First, tail is a real field, not a computed walk. Every mutation that touches the last node updates it: append advances it, remove rolls it back when the tail itself is removed, prepend initialises it on an empty list. Second, size is incremented and decremented in lockstep with mutations — never computed. Third, remove is split into three cases: empty list, head removal, and walk-and-rewire. The split is what makes the head case O(1) and lets the walk case detect the tail-removal sub-case cleanly. Fourth, Symbol.iterator is a generator — three lines instead of fifteen to implement the iterator protocol by hand.
Start with list = new LinkedList(). Track head, tail, size after each line.
list.append(1); // head→{1}, tail→{1}, size=1 (empty-list branch in append)
list.append(2); // head→{1}→{2}, tail→{2}, size=2
list.append(3); // head→{1}→{2}→{3}, tail→{3}, size=3
list.prepend(0); // head→{0}→{1}→{2}→{3}, tail→{3}, size=4
Now call list.remove(2). This is the most illuminating case — it's the "walk and rewire" branch.
this.head is {0}, not null. Continue.this.head.value is 0, target is 2. No. Continue to the walk.prev = head = {0}. prev.next is {1}. prev.next.value is 1, not 2. Advance: prev = prev.next = {1}.prev = {1}. prev.next is {2}. prev.next.value is 2 — hit. Set prev.next = prev.next.next = {3}. The chain is now {0}→{1}→{3} (node {2} is orphaned and will be garbage-collected). prev.next is {3}, not null, so tail does not change. Decrement size to 3. Return true.The post-state is head→{0}→{1}→{3}, tail→{3}, size=3. Calling list.toArray() now returns [0, 1, 3].
For comparison, here's what the head and tail removal cases look like — both are about pointer reassignment, but at opposite ends of the chain:
head. If you write this.tail.next = node without first checking that tail is null, you'll throw Cannot read properties of null (reading 'next') on the very first append. Always branch on this.tail === null and set both pointers.tail. If you only touch head (e.g. if (head.value === target) head = head.next), tail still points at the now-orphaned node. Next append does this.tail.next = node and silently extends a list you thought was empty — size says 1, head is null, and your iteration yields zero values while the new node is unreachable.tail. Same bug, different trigger. After prev.next = prev.next.next, if prev.next is now null, you've removed the tail; you must set this.tail = prev. Forget that and tail keeps pointing at the removed node; the next append hooks onto a phantom.while loop that keeps walking after a match removes ALL copies, which the spec forbids. remove(2) on [1, 2, 2, 3] should produce [1, 2, 3], not [1, 3]. Return immediately after the first rewire.get(-1) returning the last element by accident. If your get is return this.toArray()[index], then get(-1) returns the tail (because that's how Array indexing wraps for at-style methods — except it doesn't, but spread + index sometimes does in your head). Per the spec, any out-of-range index returns undefined. Guard with if (index < 0 || index >= this.size) return undefined.prev pointer to every node. remove(tail) drops from O(n) to O(1) because the tail can find its predecessor via tail.prev instead of walking from head. The cost is twice the pointer bookkeeping on every mutation — every append, prepend, and remove updates two pointers instead of one.insertAt(index, value) and removeAt(index). Index-based mutation is the natural follow-up. Both are O(n) (you have to walk to the position), and both have the same head/tail/middle case split — pulling the walk into a helper that returns the predecessor node, like the prev variable inside remove, keeps the variants short.next points back to an earlier node), detect it without using extra memory. Two pointers — one advancing one step at a time, one advancing two — will meet inside the cycle if one exists. The Symbol.iterator you wrote here would loop forever on a cyclic list; the algorithm is what makes traversal safe.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Implement a singly linked list — a chain of nodes where each node holds a value and a pointer to the next node. Unlike an Array, there is no contiguous block of memory; you walk the chain one .next at a time. Your list tracks both a head (first node) and a tail (last node), plus a size counter that stays accurate across every mutation.
You're building a class with five methods: append, prepend, remove, get, and find. The list is also iterable, so [...list] and for (const v of list) work.
class LinkedList<T> {
head: { value: T; next: Node | null } | null;
tail: { value: T; next: Node | null } | null;
size: number;
append(value: T): void; // add to the end
prepend(value: T): void; // add to the front
remove(value: T): boolean; // remove first occurrence; return true if removed
get(index: number): T | undefined; // value at index, or undefined if out of range
find(predicate: (v: T) => boolean): Node | null; // first matching node, or null
toArray(): T[]; // values, in order
[Symbol.iterator](): Iterator<T>; // iteration support
}
const list = new LinkedList();
list.append(1);
list.append(2);
list.append(3);
list.toArray(); // [1, 2, 3]
list.size; // 3
list.prepend(0);
list.toArray(); // [0, 1, 2, 3]
list.remove(2); // true
list.toArray(); // [0, 1, 3]
list.size; // 3
list.get(0); // 0
list.get(99); // undefined
list.find((v) => v > 0)?.value; // 1
const empty = new LinkedList();
empty.remove(42); // false — no-op on an empty list, never throws
empty.size; // 0
empty.toArray(); // []
[...empty]; // []
head, tail, and size. Every mutation updates all three correctly. append on an empty list must set both head and tail to the new node.remove(value) removes the first occurrence only. If the value appears twice, the second occurrence stays. Return true when something was removed, false otherwise.head, tail removal walks to the penultimate node and clears tail, removing a singleton empties both pointers.get(index) returns undefined for out-of-range indices (negative or >= size). Don't throw.find takes a predicate, not a value. Return the first node (not the value) whose value satisfies the predicate, or null.[Symbol.iterator] lets for…of and spread work for free.Array under the hood. Maintaining head/tail/next pointers is the whole point of the exercise.You're building a class that stitches plain objects into a chain via next pointers, with a few cached shortcuts (head, tail, size) that keep the common operations cheap.
A linked list is what you'd build if you didn't have Array. Each "node" is a tiny object holding a value and a pointer to the next node — like train carriages clipped together. To find the fifth carriage, you walk from the front, one coupling at a time. There's no arr[4] shortcut; the only API the chain exposes is "give me the next one." That sounds slow, but it buys you something Array can't: inserting a new carriage between two existing ones is just rewiring two pointers — no shifting elements.
Your job is to wrap that chain in a class that exposes the operations users expect (append, prepend, remove, get, find) while keeping three pieces of bookkeeping in sync on every mutation: the head pointer, the tail pointer, and a size counter.
A list is three things:
{ value, next }. The last node's next is null.head field pointing at the first node (or null if empty).tail field pointing at the last node (or null if empty), plus a size counter.head and tail aren't part of the chain — they're shortcuts on the list itself so append doesn't have to walk to the end every time.
Two pictures of the same fact, depending on which end you mutate:
A reasonable first cut keeps a head and writes everything in terms of walking from head. No tail, no size counter — "I'll just count when asked."
class NaiveList {
constructor() {
this.head = null;
}
append(value) {
const node = { value, next: null };
if (!this.head) {
this.head = node;
return;
}
// Walk to the end every time.
let cur = this.head;
while (cur.next) cur = cur.next;
cur.next = node;
}
get size() {
let n = 0;
let cur = this.head;
while (cur) { n++; cur = cur.next; }
return n;
}
}
This is correct in the "tests pass on a 3-element list" sense, but it has a concrete failure: append is now O(n). Build a list with a million calls to append and you've done roughly 0 + 1 + 2 + ... + 999,999 pointer hops — about 500 billion. The same workload with a cached tail does a million constant-time pointer assignments and finishes in a blink. A computed size getter has the same shape: every read walks the list. A simple console.log(list.size) in a hot loop turns O(1) reads into O(n).
Caching tail and size is the fix. They cost two pointer updates and one integer increment per mutation; in return, append is O(1) and size is a property read.
class LinkedList {
constructor() {
// Three pieces of bookkeeping that must stay in sync on every mutation.
// An empty list has both head and tail null; non-empty has both non-null.
this.head = null;
this.tail = null;
this.size = 0;
}
append(value) {
const node = { value, next: null };
if (this.tail === null) {
// Empty list: the new node is BOTH head and tail. Forgetting to set
// head here is the classic append-on-empty bug.
this.head = node;
this.tail = node;
} else {
// Non-empty: hook the new node onto the old tail, then advance tail.
// Order matters: write tail.next first, then reassign tail.
this.tail.next = node;
this.tail = node;
}
this.size++;
}
prepend(value) {
// The new node's next is whatever was previously the head (possibly null).
// Then head moves to the new node. tail only changes if the list was empty.
const node = { value, next: this.head };
this.head = node;
if (this.tail === null) this.tail = node;
this.size++;
}
remove(value) {
// Empty list: nothing to do. Return false so callers can branch on it.
if (this.head === null) return false;
// Case 1: removing the head. No walking; just bump head forward.
if (this.head.value === value) {
this.head = this.head.next;
// If that was the only node, the list is now empty — clear tail too.
if (this.head === null) this.tail = null;
this.size--;
return true;
}
// Case 2: walk until we find a node whose NEXT holds the value. We need
// a handle on the previous node to rewire its `next` past the doomed one.
let prev = this.head;
while (prev.next !== null) {
if (prev.next.value === value) {
// Rewire to skip past the target. If the target was the tail,
// prev becomes the new tail and we must update the cache.
prev.next = prev.next.next;
if (prev.next === null) this.tail = prev;
this.size--;
return true;
}
prev = prev.next;
}
// Walked to the end without finding the value.
return false;
}
get(index) {
// Out-of-range (negative or past the end) returns undefined — don't throw.
if (index < 0 || index >= this.size) return undefined;
let cur = this.head;
for (let i = 0; i < index; i++) cur = cur.next;
return cur.value;
}
find(predicate) {
// Returns the first NODE (not the value) whose value matches.
// Returning the node lets the caller inspect `.next`, which is useful
// for in-place mutations the public API doesn't expose.
let cur = this.head;
while (cur !== null) {
if (predicate(cur.value)) return cur;
cur = cur.next;
}
return null;
}
toArray() {
const out = [];
for (let cur = this.head; cur !== null; cur = cur.next) {
out.push(cur.value);
}
return out;
}
*[Symbol.iterator]() {
// A generator is the shortest correct iterator. Each `yield` suspends
// until the consumer asks for the next value, so `for...of` and spread
// work without manually building { next, done } objects.
for (let cur = this.head; cur !== null; cur = cur.next) {
yield cur.value;
}
}
}
module.exports = { LinkedList };
The shifts from the naive version are small but load-bearing. First, tail is a real field, not a computed walk. Every mutation that touches the last node updates it: append advances it, remove rolls it back when the tail itself is removed, prepend initialises it on an empty list. Second, size is incremented and decremented in lockstep with mutations — never computed. Third, remove is split into three cases: empty list, head removal, and walk-and-rewire. The split is what makes the head case O(1) and lets the walk case detect the tail-removal sub-case cleanly. Fourth, Symbol.iterator is a generator — three lines instead of fifteen to implement the iterator protocol by hand.
Start with list = new LinkedList(). Track head, tail, size after each line.
list.append(1); // head→{1}, tail→{1}, size=1 (empty-list branch in append)
list.append(2); // head→{1}→{2}, tail→{2}, size=2
list.append(3); // head→{1}→{2}→{3}, tail→{3}, size=3
list.prepend(0); // head→{0}→{1}→{2}→{3}, tail→{3}, size=4
Now call list.remove(2). This is the most illuminating case — it's the "walk and rewire" branch.
this.head is {0}, not null. Continue.this.head.value is 0, target is 2. No. Continue to the walk.prev = head = {0}. prev.next is {1}. prev.next.value is 1, not 2. Advance: prev = prev.next = {1}.prev = {1}. prev.next is {2}. prev.next.value is 2 — hit. Set prev.next = prev.next.next = {3}. The chain is now {0}→{1}→{3} (node {2} is orphaned and will be garbage-collected). prev.next is {3}, not null, so tail does not change. Decrement size to 3. Return true.The post-state is head→{0}→{1}→{3}, tail→{3}, size=3. Calling list.toArray() now returns [0, 1, 3].
For comparison, here's what the head and tail removal cases look like — both are about pointer reassignment, but at opposite ends of the chain:
head. If you write this.tail.next = node without first checking that tail is null, you'll throw Cannot read properties of null (reading 'next') on the very first append. Always branch on this.tail === null and set both pointers.tail. If you only touch head (e.g. if (head.value === target) head = head.next), tail still points at the now-orphaned node. Next append does this.tail.next = node and silently extends a list you thought was empty — size says 1, head is null, and your iteration yields zero values while the new node is unreachable.tail. Same bug, different trigger. After prev.next = prev.next.next, if prev.next is now null, you've removed the tail; you must set this.tail = prev. Forget that and tail keeps pointing at the removed node; the next append hooks onto a phantom.while loop that keeps walking after a match removes ALL copies, which the spec forbids. remove(2) on [1, 2, 2, 3] should produce [1, 2, 3], not [1, 3]. Return immediately after the first rewire.get(-1) returning the last element by accident. If your get is return this.toArray()[index], then get(-1) returns the tail (because that's how Array indexing wraps for at-style methods — except it doesn't, but spread + index sometimes does in your head). Per the spec, any out-of-range index returns undefined. Guard with if (index < 0 || index >= this.size) return undefined.prev pointer to every node. remove(tail) drops from O(n) to O(1) because the tail can find its predecessor via tail.prev instead of walking from head. The cost is twice the pointer bookkeeping on every mutation — every append, prepend, and remove updates two pointers instead of one.insertAt(index, value) and removeAt(index). Index-based mutation is the natural follow-up. Both are O(n) (you have to walk to the position), and both have the same head/tail/middle case split — pulling the walk into a helper that returns the predecessor node, like the prev variable inside remove, keeps the variants short.next points back to an earlier node), detect it without using extra memory. Two pointers — one advancing one step at a time, one advancing two — will meet inside the cycle if one exists. The Symbol.iterator you wrote here would loop forever on a cyclic list; the algorithm is what makes traversal safe.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.