You have an array of row objects (think: a list of users, orders, log lines) and a separate object describing which rows to keep. The description is a small, declarative tree — it mixes per-field comparisons (age >= 18, country === 'US') with logical operators (and, or, not) that can nest arbitrarily. Implement select(rows, query) so it returns a new array of rows that match. This is the same idea as a SQL WHERE clause, a MongoDB find filter, or lodash's _.filter with a richer spec — a query language for an in-memory dataset.
select(rows: object[], query: Query): object[]
type Query =
| { op: 'and'; conditions: Query[] }
| { op: 'or'; conditions: Query[] }
| { op: 'not'; condition: Query }
| { field: string; comparator: Comparator; value: unknown }
type Comparator =
| 'eq' | 'neq'
| 'gt' | 'gte' | 'lt' | 'lte'
| 'in' | 'nin'
| 'contains' | 'startsWith'
// 1. A single comparator — keep rows where age is at least 18.
select(
[{ name: 'Ana', age: 25 }, { name: 'Bo', age: 15 }],
{ field: 'age', comparator: 'gte', value: 18 },
);
// → [{ name: 'Ana', age: 25 }]
// 2. AND / OR / NOT — keep US adults who are not banned.
select(rows, {
op: 'and',
conditions: [
{ field: 'age', comparator: 'gte', value: 18 },
{ field: 'country', comparator: 'eq', value: 'US' },
{ op: 'not', condition: { field: 'banned', comparator: 'eq', value: true } },
],
});
// 3. Nested logical — admins or owners in California.
select(rows, {
op: 'and',
conditions: [
{
op: 'or',
conditions: [
{ field: 'role', comparator: 'eq', value: 'admin' },
{ field: 'role', comparator: 'eq', value: 'owner' },
],
},
{ field: 'address.state', comparator: 'eq', value: 'CA' },
],
});
// 4. Dot-path field access — read nested fields on each row.
select(
[{ name: 'Ana', address: { city: 'NYC' } }, { name: 'Bo', address: { city: 'LA' } }],
{ field: 'address.city', comparator: 'eq', value: 'NYC' },
);
// → [{ name: 'Ana', address: { city: 'NYC' } }]
eq/neq are strict ===/!==. gt/gte/lt/lte use JS >/>=/</<= (numeric on numbers, lexicographic on strings). in/nin expect value to be an array. contains works on strings AND arrays. startsWith is strings only.field: 'a.b.c' walks row.a.b.c. If any intermediate segment is null or undefined, the lookup returns undefined (no throw). A literal dot inside a key ({ "a.b": 1 }) is not addressable through this syntax — same limitation as lodash _.get.{ op: 'and', conditions: [] } matches every row; { op: 'or', conditions: [] } matches none. This mirrors Array.prototype.every([]) and Array.prototype.some([]).comparator or unknown op should throw. Silent false hides typos.rows or the query. Preserve input order.You'll write a tiny interpreter for a declarative query language: walk a tree of conditions, ask the row a yes-or-no question at each leaf, and combine the verdicts with the logical operators on the way back up.
You have a pile of rows — users, orders, log lines — and a separate object that describes which ones to keep. That description isn't a string of SQL or a function the caller wrote; it's a data structure. A tree, in fact. Interior nodes are logical operators (and, or, not) and the leaves are field-comparator-value triples like "age is at least 18" or "role is in [admin, owner]." Your job is to evaluate that tree against each row and return the ones that match.
This is the same shape as a SQL WHERE clause, a MongoDB find filter, or Drizzle's query builder. Real-world systems use query trees because trees are easy to build at runtime, easy to serialize over a wire, and easy to inspect for security ("which fields did the user try to read?"). The implementation is small but the patterns inside it — a recursive evaluator over a data tree, a dispatch table for the leaves, a null-safe path walk — show up everywhere there's a mini language to interpret.
Two recursive walks live inside this solution, and they're independent.
The first walks the query tree. Each node is one of four shapes: an and group, an or group, a not wrapper, or a leaf with { field, comparator, value }. For each row, you call an evaluate(row, query) function that branches on the node's shape. If it's a group, you recurse into each child and combine the results with every (for and) or some (for or). If it's a not, you invert the child's verdict. If it's a leaf, you stop recursing and ask the row a concrete question.
The second walk happens at each leaf. The field is something like 'address.city' — a dot-separated path. You split it into segments and walk one level into the row per segment, stopping cleanly if any intermediate is null or undefined. This is the same null-safe descent lodash's _.get does, and you'll reuse the idea here as a small helper.
Both walks reduce a tree-shaped input to a single value. The query walk produces a boolean per row; the path walk produces the value at that path (or undefined). The select function on top is a one-liner around the query walk: rows.filter(row => matches(row, query)).
Two naive approaches show up reliably for this problem. Each fails for a different, instructive reason.
eval itIf you've never written an interpreter before, the obvious move is to turn the query into JavaScript source and run it:
function selectByString(rows, query) {
// For one leaf: build something like '(row.age >= 18)'.
const expr = buildJsString(query); // recurses, concatenating
const fn = new Function('row', `return ${expr};`);
return rows.filter(fn);
}
function buildJsString(q) {
if (q.op === 'and') return '(' + q.conditions.map(buildJsString).join(' && ') + ')';
if (q.op === 'or') return '(' + q.conditions.map(buildJsString).join(' || ') + ')';
if (q.op === 'not') return '!(' + buildJsString(q.condition) + ')';
// Leaf: 'row.<field> === <value>'
return `row.${q.field} ${jsOp(q.comparator)} ${JSON.stringify(q.value)}`;
}
The result executes, the AND/OR plumbing falls out of && and ||, and for trusted inputs it even feels clever. Two problems show up the moment a real user touches it.
The first is code injection. If the query comes from an HTTP body — and in the real world it almost always does — anyone can pass { field: 'name', comparator: 'eq', value: '; while(1){}; "' }. Your buildJsString happily interpolates that into the source you hand to new Function. You've shipped eval with a public endpoint. There is no string-escaping fix that closes this hole reliably; the only safe answer is to never build code from input.
The second is field access. The string row.address.city works if and only if every intermediate is defined; the moment a row is missing address, you get TypeError: Cannot read properties of undefined. You can dress that up with optional chaining (row?.address?.city) but you've still picked the wrong abstraction — you're generating source and asking the engine to parse it on every call, when you could be walking the tree directly.
The other naive shape is the right idea (interpret, don't compile) implemented with a chain of conditionals at the leaves:
function evaluate(row, q) {
if (q.op === 'and') return q.conditions.every((c) => evaluate(row, c));
if (q.op === 'or') return q.conditions.some((c) => evaluate(row, c));
if (q.op === 'not') return !evaluate(row, q.condition);
// Leaf — nested if/else by comparator name.
const a = row[q.field], b = q.value;
if (q.comparator === 'eq') return a === b;
if (q.comparator === 'neq') return a !== b;
if (q.comparator === 'gt') return a > b;
if (q.comparator === 'gte') return a >= b;
// ... six more branches ...
return false;
}
This works for the cases listed. It also has two design smells that will bite you the next sprint. Adding a comparator means editing the evaluator — the function grows by code every time the query language gets richer. Worse, it returns false on an unknown comparator, which means a typo in a caller's query ('startswith' instead of 'startsWith') silently produces "no rows match" instead of an error. Hours of debugging.
The fix for both smells is the same: extract the leaves into a dispatch table. The evaluator branches on three logical ops and then hands off to one of N comparator functions, looked up by name. Adding regex later is one new entry; the evaluator never changes. An unknown name is undefined in the table, which trivially throws.
// Comparator dispatch table — each entry takes (rowValue, queryValue) and
// returns a boolean. Adding a new comparator is one line in this object; the
// evaluator below never has to change.
const COMPARATORS = {
eq: (a, b) => a === b,
neq: (a, b) => a !== b,
gt: (a, b) => a > b,
gte: (a, b) => a >= b,
lt: (a, b) => a < b,
lte: (a, b) => a <= b,
in: (a, b) => Array.isArray(b) && b.includes(a),
nin: (a, b) => Array.isArray(b) && !b.includes(a),
contains: (a, b) =>
(typeof a === 'string' && a.includes(b)) ||
(Array.isArray(a) && a.includes(b)),
startsWith: (a, b) => typeof a === 'string' && a.startsWith(b),
};
// Walk a dot-separated path through a row. Stops cleanly at null/undefined
// instead of throwing — same null-safe contract as lodash `_.get`.
function getPath(obj, path) {
return path
.split('.')
.reduce((acc, key) => (acc != null ? acc[key] : undefined), obj);
}
// Recursive evaluator. One node at a time: logical op nodes recurse into
// children; leaf nodes look up the field and run the comparator.
function matches(row, q) {
if (q.op === 'and') return q.conditions.every((c) => matches(row, c));
if (q.op === 'or') return q.conditions.some((c) => matches(row, c));
if (q.op === 'not') return !matches(row, q.condition);
// Anything with an `op` property must be one of the three above. A typo like
// 'xor' should fail loudly, not silently match nothing.
if (q.op !== undefined) {
throw new Error(`Unknown op: ${q.op}`);
}
const cmp = COMPARATORS[q.comparator];
if (!cmp) throw new Error(`Unknown comparator: ${q.comparator}`);
return cmp(getPath(row, q.field), q.value);
}
function select(rows, query) {
// Array.prototype.filter returns a NEW array. Input order is preserved by
// construction; the input array is untouched.
return rows.filter((row) => matches(row, query));
}
module.exports = { select };
Take the non-obvious choices one at a time.
Why a dispatch table over a switch. Beyond the extensibility argument from the naive section, the table form is cheaper to read. Each entry is a one-line lambda; you can scan the whole language at a glance. A long if-chain forces you to mentally group "everything about gt" across several lines, and the linker between the chain and the evaluator (the else ladder) is dead weight.
Why every for and and some for or. Two reasons. First, they short-circuit: every bails out the moment a child returns false; some bails the moment one returns true. For a deeply-nested AND, that means the evaluator doesn't pay for sub-conditions it doesn't need to ask about. Second, the vacuous truth and vacuous false cases drop out for free: [].every(f) === true and [].some(f) === false. The empty-AND test ("matches every row") and the empty-OR test ("matches no row") are the spec — they're also exactly what JavaScript gives you. No special case in the evaluator.
Why getPath uses reduce with acc != null ? acc[key] : undefined. The != null (loose, not !==) is intentional — it matches both null and undefined in one check. If any intermediate is nullish, every subsequent step returns undefined without ever doing a property access, so a missing address on a row that asks for address.city returns undefined instead of throwing. This is exactly the contract lodash _.get ships, and it's what your callers expect.
Why we throw on unknown comparator and unknown op instead of returning false. Silent false is the worst possible failure mode: the query "runs," returns zero rows, and the caller assumes the data is empty rather than the query is broken. Throwing turns "no rows match" into a stack trace pointing at the bad query node. The cost is two more lines of code; the win is hours of debugging the first time someone writes 'startswith'.
Why contains is overloaded for strings and arrays. Two real use cases share the same English word: "does this string contain that substring?" and "does this array contain that element?" Splitting them into containsString and containsItem would be cleaner from a type-theory standpoint but worse for the user — they already think of both as "contains." The implementation is one short || that costs nothing; the API stays one word.
Why we don't mutate rows. Array.prototype.filter always returns a new array. The input is read-only by construction. If you wrote this with a for loop and .push, you'd have to be careful not to accidentally push into the input; with filter you can't.
Why the path is split on '.'. Convention. Keys that literally contain a dot — { 'a.b': 1 } — aren't addressable through this syntax, which is the same limitation get has and the same one every JSONPath-style library has had since the beginning. The escape hatch (if you ever need it) is to accept array paths too — ['a.b', 'c'] — but for this question we stick to the simpler string form.
Why the op check is q.op !== undefined. The three explicit if (q.op === 'and' | 'or' | 'not') branches handle the known ops; the leaf branch reads q.comparator. If somebody passes { op: 'xor' }, we need to catch it before falling through to the comparator branch, where q.comparator would be undefined, give a misleading "Unknown comparator: undefined" error. The dedicated check makes the error message accurate.
Two concrete traces — one nested-logical, one dot-path-with-NOT. They're picked to exercise different parts of the evaluator.
const rows = [
{ id: 1, role: 'admin', country: 'US', banned: false },
{ id: 2, role: 'user', country: 'US', banned: false },
{ id: 3, role: 'owner', country: 'UK', banned: false },
{ id: 4, role: 'user', country: 'US', banned: true },
];
select(rows, {
op: 'and',
conditions: [
{ op: 'or', conditions: [
{ field: 'role', comparator: 'eq', value: 'admin' },
{ field: 'role', comparator: 'eq', value: 'owner' },
]},
{ field: 'country', comparator: 'eq', value: 'US' },
{ op: 'not', condition: { field: 'banned', comparator: 'eq', value: true } },
],
});
The evaluator runs once per row.
admin, US, not banned). and recurses into three children. First child is the or: recurses into the role-eq-admin leaf, 'admin' === 'admin', true — some short-circuits and returns true. Second child is the country-eq leaf, 'US' === 'US', true. Third is not(banned === true): leaf returns false === true, false; not inverts to true. All three children true, every returns true. KEEP.user, US, not banned). or recurses: admin? no. owner? no. some returns false. every short-circuits — the other two conditions are never asked. DROP.owner, UK, not banned). or returns true on the owner leaf. country-eq: 'UK' === 'US', false. every short-circuits — the not is skipped. DROP.user, US, banned). or: neither admin nor owner — false. every short-circuits. DROP.Result: [row1]. The diagram below pictures the row 1 case — every node lit green as the verdict propagates up.
const rows = [
{ name: 'A', address: { city: 'NYC' } },
{ name: 'B', address: { city: 'LA' } },
{ name: 'C' /* no address */ },
];
select(rows, {
op: 'not',
condition: { field: 'address.city', comparator: 'eq', value: 'NYC' },
});
not recurses. Leaf calls getPath(rowA, 'address.city'): split into ['address', 'city'], reduce — first step rowA.address is { city: 'NYC' }, second step is 'NYC'. Comparator eq: 'NYC' === 'NYC', true. not inverts to false. DROP.'LA'. eq: 'LA' === 'NYC', false. not → true. KEEP.getPath(rowC, 'address.city'): first reduce step, acc = rowC, acc.address is undefined. Second step, acc != null is false (because acc === undefined), so the reducer returns undefined without doing any access. eq(undefined, 'NYC') is undefined === 'NYC', false. not → true. KEEP.Result: [rowB, rowC]. Notice what didn't happen on row C: no TypeError, no exception, just a clean undefined flowing through the leaf.
new Function(...) or eval(...) on any interpolated query field is an immediate code-injection hole. { value: '; alert(1); //' } becomes a working exploit the moment it reaches a stringified eval. The interpret-don't-compile design avoids this entirely — the comparator functions can't accidentally execute arbitrary values because they only call known JS operators on them.select(rows, { op: 'and', conditions: [] }) returns every row; select(rows, { op: 'or', conditions: [] }) returns none. This matches every([]) and some([]) but it's easy to forget when you're dynamically building a query and your loop produces zero conditions. If you'd rather treat empty as an error, validate the query before calling the evaluator — don't bake the policy into the evaluator itself.{ 'user.name': 'Ana' } is unreachable via field: 'user.name' — the splitter sees two segments. Same caveat as _.get. If you must support those keys, extend the API to accept an array path (['user.name']) and skip the split.contains is overloaded. Document that the same comparator handles "string contains substring" and "array contains element." Callers who don't read carefully will be confused when { comparator: 'contains', value: 'gold' } matches both a tag array containing 'gold' and a description string containing the letters gold. The behaviour is correct; it's just not obvious without a comment.in requires value to be an array. If a caller passes { comparator: 'in', value: 'admin' } (a string, not an array), our implementation returns false for every row because Array.isArray('admin') is false. That's safer than letting 'admin'.includes(role) quietly do a substring check, but you might prefer to throw — pick a policy and document it.getPath returns undefined for a missing field; the comparator then runs undefined === 'NYC' and returns false. That's usually what you want. But gt(undefined, 0) is undefined > 0 which is false — and lt(undefined, 0) is also false. A row with no age field never matches any numeric inequality, even age < 100. If you want missing fields to participate in comparisons, normalise them upstream.false and "be forgiving." That's a footgun: a typo in the query name means "no rows match" instead of "your query has a bug." A thrown error points the caller at the exact node that broke; silent false sends them on a wild goose chase through the data.and([gte(age, 18), eq(country, 'US')]) becomes row => row.age >= 18 && row.country === 'US'. Same semantics, one closure-call per row instead of three function calls plus an array lookup. The implementation is a compile(query) that returns a function; select becomes rows.filter(compile(query)). The compiler is safe — it uses the dispatch table to construct nested closures, not new Function — so the code-injection risk from naive attempt 1 doesn't return.select(rows, { where, project }) where project is an array of dot-paths and the function returns a new array of projected rows. The matching code is unchanged; you add a post-filter map that builds the smaller object from the listed paths. This is what makes the function feel like a real query language instead of just a filter.groupBy and you've built the front half of an in-memory ORM. Add aggregate comparators ({ comparator: 'count_gte', value: 10 }) and you can express "departments with at least ten employees" directly in the query tree. Drizzle's query builder is a real-world version — it builds a tree shaped almost identically to this one and compiles it down to SQL instead of a JS predicate. Same data, different backend.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You have an array of row objects (think: a list of users, orders, log lines) and a separate object describing which rows to keep. The description is a small, declarative tree — it mixes per-field comparisons (age >= 18, country === 'US') with logical operators (and, or, not) that can nest arbitrarily. Implement select(rows, query) so it returns a new array of rows that match. This is the same idea as a SQL WHERE clause, a MongoDB find filter, or lodash's _.filter with a richer spec — a query language for an in-memory dataset.
select(rows: object[], query: Query): object[]
type Query =
| { op: 'and'; conditions: Query[] }
| { op: 'or'; conditions: Query[] }
| { op: 'not'; condition: Query }
| { field: string; comparator: Comparator; value: unknown }
type Comparator =
| 'eq' | 'neq'
| 'gt' | 'gte' | 'lt' | 'lte'
| 'in' | 'nin'
| 'contains' | 'startsWith'
// 1. A single comparator — keep rows where age is at least 18.
select(
[{ name: 'Ana', age: 25 }, { name: 'Bo', age: 15 }],
{ field: 'age', comparator: 'gte', value: 18 },
);
// → [{ name: 'Ana', age: 25 }]
// 2. AND / OR / NOT — keep US adults who are not banned.
select(rows, {
op: 'and',
conditions: [
{ field: 'age', comparator: 'gte', value: 18 },
{ field: 'country', comparator: 'eq', value: 'US' },
{ op: 'not', condition: { field: 'banned', comparator: 'eq', value: true } },
],
});
// 3. Nested logical — admins or owners in California.
select(rows, {
op: 'and',
conditions: [
{
op: 'or',
conditions: [
{ field: 'role', comparator: 'eq', value: 'admin' },
{ field: 'role', comparator: 'eq', value: 'owner' },
],
},
{ field: 'address.state', comparator: 'eq', value: 'CA' },
],
});
// 4. Dot-path field access — read nested fields on each row.
select(
[{ name: 'Ana', address: { city: 'NYC' } }, { name: 'Bo', address: { city: 'LA' } }],
{ field: 'address.city', comparator: 'eq', value: 'NYC' },
);
// → [{ name: 'Ana', address: { city: 'NYC' } }]
eq/neq are strict ===/!==. gt/gte/lt/lte use JS >/>=/</<= (numeric on numbers, lexicographic on strings). in/nin expect value to be an array. contains works on strings AND arrays. startsWith is strings only.field: 'a.b.c' walks row.a.b.c. If any intermediate segment is null or undefined, the lookup returns undefined (no throw). A literal dot inside a key ({ "a.b": 1 }) is not addressable through this syntax — same limitation as lodash _.get.{ op: 'and', conditions: [] } matches every row; { op: 'or', conditions: [] } matches none. This mirrors Array.prototype.every([]) and Array.prototype.some([]).comparator or unknown op should throw. Silent false hides typos.rows or the query. Preserve input order.You'll write a tiny interpreter for a declarative query language: walk a tree of conditions, ask the row a yes-or-no question at each leaf, and combine the verdicts with the logical operators on the way back up.
You have a pile of rows — users, orders, log lines — and a separate object that describes which ones to keep. That description isn't a string of SQL or a function the caller wrote; it's a data structure. A tree, in fact. Interior nodes are logical operators (and, or, not) and the leaves are field-comparator-value triples like "age is at least 18" or "role is in [admin, owner]." Your job is to evaluate that tree against each row and return the ones that match.
This is the same shape as a SQL WHERE clause, a MongoDB find filter, or Drizzle's query builder. Real-world systems use query trees because trees are easy to build at runtime, easy to serialize over a wire, and easy to inspect for security ("which fields did the user try to read?"). The implementation is small but the patterns inside it — a recursive evaluator over a data tree, a dispatch table for the leaves, a null-safe path walk — show up everywhere there's a mini language to interpret.
Two recursive walks live inside this solution, and they're independent.
The first walks the query tree. Each node is one of four shapes: an and group, an or group, a not wrapper, or a leaf with { field, comparator, value }. For each row, you call an evaluate(row, query) function that branches on the node's shape. If it's a group, you recurse into each child and combine the results with every (for and) or some (for or). If it's a not, you invert the child's verdict. If it's a leaf, you stop recursing and ask the row a concrete question.
The second walk happens at each leaf. The field is something like 'address.city' — a dot-separated path. You split it into segments and walk one level into the row per segment, stopping cleanly if any intermediate is null or undefined. This is the same null-safe descent lodash's _.get does, and you'll reuse the idea here as a small helper.
Both walks reduce a tree-shaped input to a single value. The query walk produces a boolean per row; the path walk produces the value at that path (or undefined). The select function on top is a one-liner around the query walk: rows.filter(row => matches(row, query)).
Two naive approaches show up reliably for this problem. Each fails for a different, instructive reason.
eval itIf you've never written an interpreter before, the obvious move is to turn the query into JavaScript source and run it:
function selectByString(rows, query) {
// For one leaf: build something like '(row.age >= 18)'.
const expr = buildJsString(query); // recurses, concatenating
const fn = new Function('row', `return ${expr};`);
return rows.filter(fn);
}
function buildJsString(q) {
if (q.op === 'and') return '(' + q.conditions.map(buildJsString).join(' && ') + ')';
if (q.op === 'or') return '(' + q.conditions.map(buildJsString).join(' || ') + ')';
if (q.op === 'not') return '!(' + buildJsString(q.condition) + ')';
// Leaf: 'row.<field> === <value>'
return `row.${q.field} ${jsOp(q.comparator)} ${JSON.stringify(q.value)}`;
}
The result executes, the AND/OR plumbing falls out of && and ||, and for trusted inputs it even feels clever. Two problems show up the moment a real user touches it.
The first is code injection. If the query comes from an HTTP body — and in the real world it almost always does — anyone can pass { field: 'name', comparator: 'eq', value: '; while(1){}; "' }. Your buildJsString happily interpolates that into the source you hand to new Function. You've shipped eval with a public endpoint. There is no string-escaping fix that closes this hole reliably; the only safe answer is to never build code from input.
The second is field access. The string row.address.city works if and only if every intermediate is defined; the moment a row is missing address, you get TypeError: Cannot read properties of undefined. You can dress that up with optional chaining (row?.address?.city) but you've still picked the wrong abstraction — you're generating source and asking the engine to parse it on every call, when you could be walking the tree directly.
The other naive shape is the right idea (interpret, don't compile) implemented with a chain of conditionals at the leaves:
function evaluate(row, q) {
if (q.op === 'and') return q.conditions.every((c) => evaluate(row, c));
if (q.op === 'or') return q.conditions.some((c) => evaluate(row, c));
if (q.op === 'not') return !evaluate(row, q.condition);
// Leaf — nested if/else by comparator name.
const a = row[q.field], b = q.value;
if (q.comparator === 'eq') return a === b;
if (q.comparator === 'neq') return a !== b;
if (q.comparator === 'gt') return a > b;
if (q.comparator === 'gte') return a >= b;
// ... six more branches ...
return false;
}
This works for the cases listed. It also has two design smells that will bite you the next sprint. Adding a comparator means editing the evaluator — the function grows by code every time the query language gets richer. Worse, it returns false on an unknown comparator, which means a typo in a caller's query ('startswith' instead of 'startsWith') silently produces "no rows match" instead of an error. Hours of debugging.
The fix for both smells is the same: extract the leaves into a dispatch table. The evaluator branches on three logical ops and then hands off to one of N comparator functions, looked up by name. Adding regex later is one new entry; the evaluator never changes. An unknown name is undefined in the table, which trivially throws.
// Comparator dispatch table — each entry takes (rowValue, queryValue) and
// returns a boolean. Adding a new comparator is one line in this object; the
// evaluator below never has to change.
const COMPARATORS = {
eq: (a, b) => a === b,
neq: (a, b) => a !== b,
gt: (a, b) => a > b,
gte: (a, b) => a >= b,
lt: (a, b) => a < b,
lte: (a, b) => a <= b,
in: (a, b) => Array.isArray(b) && b.includes(a),
nin: (a, b) => Array.isArray(b) && !b.includes(a),
contains: (a, b) =>
(typeof a === 'string' && a.includes(b)) ||
(Array.isArray(a) && a.includes(b)),
startsWith: (a, b) => typeof a === 'string' && a.startsWith(b),
};
// Walk a dot-separated path through a row. Stops cleanly at null/undefined
// instead of throwing — same null-safe contract as lodash `_.get`.
function getPath(obj, path) {
return path
.split('.')
.reduce((acc, key) => (acc != null ? acc[key] : undefined), obj);
}
// Recursive evaluator. One node at a time: logical op nodes recurse into
// children; leaf nodes look up the field and run the comparator.
function matches(row, q) {
if (q.op === 'and') return q.conditions.every((c) => matches(row, c));
if (q.op === 'or') return q.conditions.some((c) => matches(row, c));
if (q.op === 'not') return !matches(row, q.condition);
// Anything with an `op` property must be one of the three above. A typo like
// 'xor' should fail loudly, not silently match nothing.
if (q.op !== undefined) {
throw new Error(`Unknown op: ${q.op}`);
}
const cmp = COMPARATORS[q.comparator];
if (!cmp) throw new Error(`Unknown comparator: ${q.comparator}`);
return cmp(getPath(row, q.field), q.value);
}
function select(rows, query) {
// Array.prototype.filter returns a NEW array. Input order is preserved by
// construction; the input array is untouched.
return rows.filter((row) => matches(row, query));
}
module.exports = { select };
Take the non-obvious choices one at a time.
Why a dispatch table over a switch. Beyond the extensibility argument from the naive section, the table form is cheaper to read. Each entry is a one-line lambda; you can scan the whole language at a glance. A long if-chain forces you to mentally group "everything about gt" across several lines, and the linker between the chain and the evaluator (the else ladder) is dead weight.
Why every for and and some for or. Two reasons. First, they short-circuit: every bails out the moment a child returns false; some bails the moment one returns true. For a deeply-nested AND, that means the evaluator doesn't pay for sub-conditions it doesn't need to ask about. Second, the vacuous truth and vacuous false cases drop out for free: [].every(f) === true and [].some(f) === false. The empty-AND test ("matches every row") and the empty-OR test ("matches no row") are the spec — they're also exactly what JavaScript gives you. No special case in the evaluator.
Why getPath uses reduce with acc != null ? acc[key] : undefined. The != null (loose, not !==) is intentional — it matches both null and undefined in one check. If any intermediate is nullish, every subsequent step returns undefined without ever doing a property access, so a missing address on a row that asks for address.city returns undefined instead of throwing. This is exactly the contract lodash _.get ships, and it's what your callers expect.
Why we throw on unknown comparator and unknown op instead of returning false. Silent false is the worst possible failure mode: the query "runs," returns zero rows, and the caller assumes the data is empty rather than the query is broken. Throwing turns "no rows match" into a stack trace pointing at the bad query node. The cost is two more lines of code; the win is hours of debugging the first time someone writes 'startswith'.
Why contains is overloaded for strings and arrays. Two real use cases share the same English word: "does this string contain that substring?" and "does this array contain that element?" Splitting them into containsString and containsItem would be cleaner from a type-theory standpoint but worse for the user — they already think of both as "contains." The implementation is one short || that costs nothing; the API stays one word.
Why we don't mutate rows. Array.prototype.filter always returns a new array. The input is read-only by construction. If you wrote this with a for loop and .push, you'd have to be careful not to accidentally push into the input; with filter you can't.
Why the path is split on '.'. Convention. Keys that literally contain a dot — { 'a.b': 1 } — aren't addressable through this syntax, which is the same limitation get has and the same one every JSONPath-style library has had since the beginning. The escape hatch (if you ever need it) is to accept array paths too — ['a.b', 'c'] — but for this question we stick to the simpler string form.
Why the op check is q.op !== undefined. The three explicit if (q.op === 'and' | 'or' | 'not') branches handle the known ops; the leaf branch reads q.comparator. If somebody passes { op: 'xor' }, we need to catch it before falling through to the comparator branch, where q.comparator would be undefined, give a misleading "Unknown comparator: undefined" error. The dedicated check makes the error message accurate.
Two concrete traces — one nested-logical, one dot-path-with-NOT. They're picked to exercise different parts of the evaluator.
const rows = [
{ id: 1, role: 'admin', country: 'US', banned: false },
{ id: 2, role: 'user', country: 'US', banned: false },
{ id: 3, role: 'owner', country: 'UK', banned: false },
{ id: 4, role: 'user', country: 'US', banned: true },
];
select(rows, {
op: 'and',
conditions: [
{ op: 'or', conditions: [
{ field: 'role', comparator: 'eq', value: 'admin' },
{ field: 'role', comparator: 'eq', value: 'owner' },
]},
{ field: 'country', comparator: 'eq', value: 'US' },
{ op: 'not', condition: { field: 'banned', comparator: 'eq', value: true } },
],
});
The evaluator runs once per row.
admin, US, not banned). and recurses into three children. First child is the or: recurses into the role-eq-admin leaf, 'admin' === 'admin', true — some short-circuits and returns true. Second child is the country-eq leaf, 'US' === 'US', true. Third is not(banned === true): leaf returns false === true, false; not inverts to true. All three children true, every returns true. KEEP.user, US, not banned). or recurses: admin? no. owner? no. some returns false. every short-circuits — the other two conditions are never asked. DROP.owner, UK, not banned). or returns true on the owner leaf. country-eq: 'UK' === 'US', false. every short-circuits — the not is skipped. DROP.user, US, banned). or: neither admin nor owner — false. every short-circuits. DROP.Result: [row1]. The diagram below pictures the row 1 case — every node lit green as the verdict propagates up.
const rows = [
{ name: 'A', address: { city: 'NYC' } },
{ name: 'B', address: { city: 'LA' } },
{ name: 'C' /* no address */ },
];
select(rows, {
op: 'not',
condition: { field: 'address.city', comparator: 'eq', value: 'NYC' },
});
not recurses. Leaf calls getPath(rowA, 'address.city'): split into ['address', 'city'], reduce — first step rowA.address is { city: 'NYC' }, second step is 'NYC'. Comparator eq: 'NYC' === 'NYC', true. not inverts to false. DROP.'LA'. eq: 'LA' === 'NYC', false. not → true. KEEP.getPath(rowC, 'address.city'): first reduce step, acc = rowC, acc.address is undefined. Second step, acc != null is false (because acc === undefined), so the reducer returns undefined without doing any access. eq(undefined, 'NYC') is undefined === 'NYC', false. not → true. KEEP.Result: [rowB, rowC]. Notice what didn't happen on row C: no TypeError, no exception, just a clean undefined flowing through the leaf.
new Function(...) or eval(...) on any interpolated query field is an immediate code-injection hole. { value: '; alert(1); //' } becomes a working exploit the moment it reaches a stringified eval. The interpret-don't-compile design avoids this entirely — the comparator functions can't accidentally execute arbitrary values because they only call known JS operators on them.select(rows, { op: 'and', conditions: [] }) returns every row; select(rows, { op: 'or', conditions: [] }) returns none. This matches every([]) and some([]) but it's easy to forget when you're dynamically building a query and your loop produces zero conditions. If you'd rather treat empty as an error, validate the query before calling the evaluator — don't bake the policy into the evaluator itself.{ 'user.name': 'Ana' } is unreachable via field: 'user.name' — the splitter sees two segments. Same caveat as _.get. If you must support those keys, extend the API to accept an array path (['user.name']) and skip the split.contains is overloaded. Document that the same comparator handles "string contains substring" and "array contains element." Callers who don't read carefully will be confused when { comparator: 'contains', value: 'gold' } matches both a tag array containing 'gold' and a description string containing the letters gold. The behaviour is correct; it's just not obvious without a comment.in requires value to be an array. If a caller passes { comparator: 'in', value: 'admin' } (a string, not an array), our implementation returns false for every row because Array.isArray('admin') is false. That's safer than letting 'admin'.includes(role) quietly do a substring check, but you might prefer to throw — pick a policy and document it.getPath returns undefined for a missing field; the comparator then runs undefined === 'NYC' and returns false. That's usually what you want. But gt(undefined, 0) is undefined > 0 which is false — and lt(undefined, 0) is also false. A row with no age field never matches any numeric inequality, even age < 100. If you want missing fields to participate in comparisons, normalise them upstream.false and "be forgiving." That's a footgun: a typo in the query name means "no rows match" instead of "your query has a bug." A thrown error points the caller at the exact node that broke; silent false sends them on a wild goose chase through the data.and([gte(age, 18), eq(country, 'US')]) becomes row => row.age >= 18 && row.country === 'US'. Same semantics, one closure-call per row instead of three function calls plus an array lookup. The implementation is a compile(query) that returns a function; select becomes rows.filter(compile(query)). The compiler is safe — it uses the dispatch table to construct nested closures, not new Function — so the code-injection risk from naive attempt 1 doesn't return.select(rows, { where, project }) where project is an array of dot-paths and the function returns a new array of projected rows. The matching code is unchanged; you add a post-filter map that builds the smaller object from the listed paths. This is what makes the function feel like a real query language instead of just a filter.groupBy and you've built the front half of an in-memory ORM. Add aggregate comparators ({ comparator: 'count_gte', value: 10 }) and you can express "departments with at least ten employees" directly in the query tree. Drizzle's query builder is a real-world version — it builds a tree shaped almost identically to this one and compiles it down to SQL instead of a JS predicate. Same data, different backend.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.