Interview Prep
Problems modelled on what technical interviews ask. Solve them in JavaScript, TypeScript, Python or SQL.
Recommended next
Easy · Node.js · Backend · ~12 min
Difficulty
Topics
Language
solve(s) — convert a valid Roman numeral (1..3999) to an integer. The symbols are I=1 V=5 X=10 L=50 C=100 D=500 M=1000. Symbols are normally written largest to smallest and added, except in six subtractive cases (IV=4 IX=9 XL=40 XC=90 CD=400 CM=900) where a smaller symbol before a larger one is subtracted. Scan left to right: when a symbol is smaller than the one after it, subtract it, otherwise add it. Examples
solve(s) — return true if s can be built by repeating a shorter substring two or more times ("abab" is "ab" twice). Trying every divisor of the length is fine, but the failure table of KMP answers it in one pass: let n = s.length and b = lps[n - 1] (the longest border of the whole string). The string is periodic exactly when b > 0 and n is divisible by n - b (the length of the smallest period). Examples
solve(strs) — return the longest string that is a prefix of every string in strs (non-empty array), or "" if there is none. Take the first string as the candidate prefix and shrink it until each other string starts with it. Or compare column by column: the prefix ends at the first index where any string is shorter or disagrees with the first one. Either way is O(total characters). Examples
solve(s, t) — two strings of the same length are isomorphic if the characters of s can be replaced to get t, with a one-to-one mapping: every character of s always maps to the same character of t, and two different characters of s never map to the same character. One map is not enough: "ab" → "aa" is a valid function but not a valid bijection. Keep two maps (s → t and t → s), or compare the index of each character's first occurrence. Examples
Replace a positive integer by the sum of the squares of its digits, and repeat. If the process reaches 1 the number is happy; otherwise it loops forever in a cycle that never contains 1. solve(n) — return true when n (1 <= n < 2^31) is happy. The sequence cannot grow without bound (a 10-digit number maps to at most 810), so it either reaches 1 or repeats a value. Detect the repeat with a Set of seen values, or in O(1) space with Floyd's slow / fast pointers on the "next number" function. Examples
solve(columnNumber) — convert a positive column number (1 <= columnNumber < 2^31) to its spreadsheet title: 1 → "A", 26 → "Z", 27 → "AA", 28 → "AB", 702 → "ZZ", 703 → "AAA". It looks like base 26, but it is bijective: there is no digit for zero (A is 1, Z is 26). The usual n % 26 loop breaks on multiples of 26. The fix is to shift by one before each division: take the letter for (n - 1) % 26, then continue with floor((n - 1) / 26). Examples
Time for tinyserve to answer HTTP. http.createServer(handler) calls handler(req, res) for every request: req.method and req.url say what is asked, res.writeHead(status, headers) and res.end(body) answer. No framework: Express is built on exactly these two objects. Write createApp() and return the server (do not call listen: the driver does). Routes depend on the pathname only — ignore the query string: | Request | Status | Content-Type | Body | |---|---|---|---| | GET /health | 200 | application/json | {"status":"ok"} | | GET /hello?name=Ada | 200 | text/plain; charset=utf-8 | Hello, Ada! (Hello, world! when name is missing or empty) | | another method on /health or /hello | 405 | application/json | {"error":"Method Not Allowed"} + header Allow: GET | | any other path | 404 | application/json | {"error":"Not Found"} | JSON bodies are JSON.stringify output, without spaces. Paths are case-sensitive. The sandbox has no TCP network, so the driver starts your server on a Unix socket (server.listen('/tmp/.../server.sock')) — your code does not change. It sends each test's requests and reports status, Content-Type, Allow and body.
Before serving a folder, tinyserve prints what it is about to publish. That means walking the folder recursively — and deciding what not to publish: .git, .env and other hidden entries. Write async listFiles(root). Return an array of { path, size } for every file under root, at any depth: path is relative to root, with / as separator: 'css/app.css'. size is the file size in bytes (from fs.stat). Skip every entry whose name starts with . — for a folder, skip everything inside it too. Empty folders produce nothing. Sort the result by path with plain string comparison (a.path < b.path), so 'B.txt' comes before 'a.txt'. Use fs.readdir(dir, { withFileTypes: true }): it returns Dirent objects with isFile() and isDirectory(), saving a stat call per entry. → [{ path: 'css/app.css', size: 6 }, { path: 'index.html', size: 1 }]
A file name can lie; its first bytes rarely do. Every PNG starts with 89 50 4E 47 0D 0A 1A 0A, every PDF with %PDF-. Node handles raw bytes with Buffer, and tinyserve will use this to describe files. Write describeFile(buffer) and return { type, size, preview }: type — the first entry of SIGNATURES whose magic bytes (written in hex) start the buffer. Otherwise: 'text/plain; charset=utf-8' if the buffer is non-empty, contains no 0x00 byte and is valid UTF-8; 'application/octet-stream' in every other case (empty buffer included). size — the length in bytes. 'héllo' is 5 characters but 6 bytes: é takes two. preview — the first 8 bytes (fewer if the buffer is shorter) as lowercase two-digit hex, separated by spaces: '89 50 4e 47 0d 0a 1a 0a'. The test driver builds the buffer from a base64 string, so the tests can carry binary data. buffer.toString('utf8') never throws: invalid sequences become � (U+FFFD). Encoding that string back gives different bytes, so Buffer.from(text, 'utf8').equals(buffer) is a valid test.
When a browser asks tinyserve for a file, Node hands over req.url: only the path and the query string, still percent-encoded — '/files/report%20v2.pdf?download=1'. Before looking for a file, the server needs the real path (/files/report v2.pdf) and the parameters. Write parseRequestTarget(target) with the WHATWG URL class and its searchParams. Return { path, params }: path — the pathname, percent-decoded with decodeURIComponent. params — one key per query parameter. A parameter that appears once maps to its string; one that appears several times maps to the array of its values, in order. + means a space. The fragment (#...) is not part of either. If the path contains a broken escape (%E0%A4%A, a lone %), decodeURIComponent throws a URIError: return { error: 'Bad Request' } — a server answers 400, it does not crash.
tinyserve reads optional settings from tinyserve.json. Reading a file is one line with fs/promises; handling what goes wrong is the actual job. Write async loadConfig(file), where file is an absolute path: If the file does not exist (error.code === 'ENOENT'), return a copy of DEFAULTS. Any other read error (e.g. EISDIR: the path is a folder) is thrown unchanged. If the content is not valid JSON, throw new Error('Invalid JSON in <file name>') — only the base name, e.g. 'Invalid JSON in tinyserve.json'. Pass the parse error as cause. If the JSON is not an object (null, an array, a number...), throw new Error('Config must be a JSON object'). Otherwise return DEFAULTS overridden by the file's values, keeping only the keys of DEFAULTS: unknown keys are ignored. The test driver writes the files of each test into a temporary folder, calls your function and reports either the result or { error: error.code ?? error.message }. → { port: 8080, root: 'public', gzip: false }
Welcome to tinyserve, the project of this path: a static file server written with nothing but Node's core modules — no npm install. Every exercise builds one piece of it. First piece: its command line. Inside a Node program, process.argv holds that command as an array. Its first two entries are the path of the node binary and of the script; what the user typed starts at index 2. Write parseServeArgs(argv). argv is shaped like process.argv. Use util.parseArgs, built into Node since v18.3, rather than looping over the strings yourself. | Option | Value | Default | |---|---|---| | first positional | folder to serve (root) | '.' | | --port, -p | port number | 3000 | | --host | interface to listen on | '127.0.0.1' | | --gzip | boolean flag | false | Return { root, port, host, gzip } with port as a number. Return { error } instead of options when: parseArgs throws (unknown option, option without its value): error is the thrown error's code, e.g. 'ERR_PARSE_ARGS_UNKNOWN_OPTION'. Node errors carry a stable code; their messages change between versions, so code that reacts to errors checks the code. more than one positional is given: 'Too many arguments'. the port is not made of digits only, or is outside 1–65535: 'Invalid port: <value>'. The solve function at the bottom is the test driver: it calls your function with each test's input. You only edit parseServeArgs.
Partial application pre-fills some arguments of a function, returning a new function that accepts the rest. Implement partial(fn, ...presetArgs) that returns a new function pre-filled with presetArgs. solve(label, ...args) dispatches to predefined partials: 'add5' → partial of (a,b)=>a+b with first arg 5 'multiply3' → partial of (a,b)=>a*b with first arg 3 'greet' → partial of (greeting,name)=>greeting+' '+name with 'Hello'
compose applies functions right-to-left: the last function runs first. Implement compose(...fns) that returns a function applying the array of functions right-to-left. solve(x, label) dispatches to a predefined composition: 'double_addOne' → compose(double, addOne) 'square_double' → compose(square, double) 'addOne_square_double' → compose(addOne, square, double)
A request for /css/app.css must turn into a file path inside the folder tinyserve serves. A request for /../../etc/passwd must not. This mistake, path traversal, has leaked files from real servers for decades, and it is exactly where naive string handling fails. Write resolveInRoot(root, urlPath) with the node:path module. root is an absolute folder, e.g. '/srv/site' (it may end with a slash). urlPath is the already-decoded path of the request; it always starts with /. Return the absolute, normalized path of the target (no ., .., double or trailing slash — what path.resolve produces), or null when it is not inside root. root itself is inside: '/' maps to '/srv/site'. A path containing a NUL character (\0) returns null. path.resolve(root, '/etc/passwd') returns /etc/passwd: an absolute argument wins. full.startsWith(root) accepts the sibling folder /srv/site-backup. Compare with path.relative(root, full) instead.
A password-reset token generator is meant to build a token from a caller-supplied source of cryptographically-random integers. The bug: the buggy version ignores the secure source entirely and calls Math.random() instead — Math.random is a fast, statistically-predictable PRNG never intended for security purposes; tokens generated from it can be predicted or brute-forced far more easily than a real attacker should need. Your task: fix solve(randomInts) so it builds the token from the provided randomInts array (mapping each integer to a character via % alphabet.length), never by generating its own randomness internally.
The signup form below accepts any string as an email address without validation. The bug: malformed input flows downstream into emails, database records and third-party APIs — at best causing bounced emails, at worst becoming an injection vector in systems that trust the "email" field is actually shaped like one. Your task: fix solve(email) to return true only for strings shaped like local@domain.tld (non-empty local part, @, non-empty domain, a dot, non-empty TLD) — false otherwise.
The function below builds a SQL query by concatenating user input directly into the string. The bug: an attacker can pass admin' OR '1'='1 as the username and the resulting query becomes SELECT * FROM users WHERE username = 'admin' OR '1'='1' — always true, bypassing authentication entirely. Your task: fix solve(username) so it returns a parameterized query — a placeholder (?) in the SQL string, with the user input passed separately as a bind parameter, never concatenated.
The file-download endpoint below serves whatever path the client asks for, relative to an uploads folder. The bug: a request for ../../etc/passwd (or an absolute path like /etc/passwd) escapes the intended uploads directory entirely, exposing arbitrary files on the server. Your task: fix solve(userPath) so it returns the path unchanged when it's a safe relative path, or null when it contains .. or starts with /.
The core of a bit.ly-style service: turn a long URL into a short code and back. solve(args, ops) builds new Shortener(startId) — args = [startId] — and replays ops: shorten(url) — return the short code for url. The first new URL gets id startId, the next new URL startId + 1, and so on. The same URL always returns the same code and does not consume an id. expand(code) — return the original URL, or null for an unknown code. The code is the id written in base 62 with the alphabet 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ (so 10 → "a", 61 → "Z", 62 → "10"). Using a counter instead of a hash guarantees there are no collisions; encode it yourself, without toString(62) (which does not exist for 62). Example (startId = 61)
A fixed-size circular buffer: the structure behind log tails, audio buffers and metric windows. Writing to a full buffer overwrites the oldest value. solve(args, ops) builds new RingBuffer(capacity) — args = [capacity] — and replays ops: write(value) — append value; if the buffer is full, drop the oldest value first. Returns nothing. read() — remove and return the oldest value, or null if the buffer is empty. snapshot() — return all values as an array, oldest first, without removing them. size() — the number of values currently stored. All operations except snapshot must be O(1): allocate the array once and move a start index and a size counter around it with modulo arithmetic — no shift(). Example (capacity = 3)
Build the counter behind a "requests in the last 5 minutes" metric. solve(args, ops) builds new HitCounter() (no constructor arguments) and replays ops: hit(timestamp) — record one hit at timestamp (seconds). Returns nothing. count(timestamp) — return how many hits happened in the last 300 seconds, that is hits with timestamp - 300 < hitTime <= timestamp. Timestamps never go backwards. Several hits can share a timestamp. Keep memory bounded: drop hits that have left the window instead of keeping the whole history (a queue works). Example