AI can ship working JavaScript fast. It also ships the exact shapes interviewers hate: giant switches, hard wired dependencies, and cross cutting concerns sprinkled everywhere. Here are 6 refactors I keep using to turn that code into something you can extend in 20 minutes under pressure.
1) Replace switch handlers with a Strategy registry
When a function keeps growing branches, you are paying a tax every time you add a new case.
Before
type PaymentMethod = "stripe" | "paypal" | "invoice"
export async function pay(method: PaymentMethod, amount: number, currency: string) {
switch (method) {
case "stripe":
return stripe.paymentIntents.create({ amount: Math.round(amount * 100), currency })
case "paypal":
return paypal.orders.create({ purchase_units: [{ amount: { value: String(amount), currency_code: currency } }] })
case "invoice":
return db.invoice.create({ data: { amount, currency, status: "pending" } })
}
}
After
export interface PaymentStrategy {
readonly name: string
charge(amount: number, currency: string): Promise<{ id: string }>
}
export class PaymentProcessor {
private strategies = new Map<string, PaymentStrategy>()
register(strategy: PaymentStrategy) {
this.strategies.set(strategy.name, strategy)
}
async charge(method: string, amount: number, currency: string) {
const strategy = this.strategies.get(method)
if (!strategy) throw new Error(`Unknown payment method: ${method}`)
return strategy.charge(amount, currency)
}
}
Now adding a new provider is a new class plus one register call. Nothing else changes, which is the whole point.
2) Stop leaking event names, ship a typed EventBus
Most interview projects have “events” but the types are a mess and cleanup is forgotten. A small typed bus fixes both.
Before
import { EventEmitter } from "node:events"
const bus = new EventEmitter()
bus.on("job:created", (job) => {
console.log(job.title.toUpperCase())
})
bus.emit("job:created", { id: 1, title: "Senior React", company: "Acme" })
After
type Listener<T> = (payload: T) => void
export class EventBus<Events extends Record<string, unknown>> {
private listeners = new Map<keyof Events, Set<Listener<any>>>()
on<K extends keyof Events>(event: K, listener: Listener<Events[K]>) {
const set = this.listeners.get(event) ?? new Set()
set.add(listener)
this.listeners.set(event, set)
return () => set.delete(listener)
}
emit<K extends keyof Events>(event: K, payload: Events[K]) {
this.listeners.get(event)?.forEach((fn) => fn(payload))
}
}
type AppEvents = {
"job:created": { id: string; title: string; company: string }
"user:login": { userId: string; at: number }
}
const bus = new EventBus<AppEvents>()
const off = bus.on("job:created", (job) => console.log(job.company))
bus.emit("job:created", { id: "1", title: "Senior React", company: "Stripe" })
off()
The unsubscribe return value kills a whole class of leaks, and the compiler enforces payloads.
3) Convert ad hoc retries into a Command queue
If you have “retry later” logic, turning actions into command objects makes it testable and observable. This pattern compounds with the JavaScript application architecture thinking used in system design rounds.
Before
async function sendEmail(to: string, html: string) {
try {
await provider.send({ to, html })
} catch (err) {
await new Promise((r) => setTimeout(r, 1000))
await provider.send({ to, html })
}
}
After
export interface Command {
key: string
run(): Promise<void>
}
export class RetryQueue {
private queue: Command[] = []
enqueue(cmd: Command) {
this.queue.push(cmd)
}
async drain(maxAttempts: number) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const pending = [...this.queue]
this.queue = []
for (const cmd of pending) {
try {
await cmd.run()
} catch {
this.enqueue(cmd)
}
}
if (this.queue.length === 0) return
await new Promise((r) => setTimeout(r, 250 * 2 ** attempt))
}
throw new Error(`Queue still has ${this.queue.length} commands after retries`)
}
}
class SendEmailCommand implements Command {
key: string
constructor(private to: string, private html: string, private provider: { send: Function }) {
this.key = `email:${to}:${html.length}`
}
async run() {
await this.provider.send({ to: this.to, html: this.html })
}
}
You can now log key, persist the queue, run it in a worker, or unit test retry behavior without mocking time inside business logic.
4) Add caching and validation with Proxy, without touching business code
Cross cutting concerns should not be hand coded into every method.
Before
const jobsApi = {
async list(category?: string) {
const res = await fetch(`/api/jobs?category=${category ?? ""}`)
if (!res.ok) throw new Error("Bad response")
return res.json()
},
}
const cache = new Map<string, any>()
async function cachedList(category?: string) {
const key = `list:${category ?? ""}`
if (cache.has(key)) return cache.get(key)
const data = await jobsApi.list(category)
cache.set(key, data)
return data
}
After
type AsyncFn = (...args: any[]) => Promise<any>
export function withCache<T extends Record<string, AsyncFn>>(target: T, ttlMs: number) {
const cache = new Map<string, { at: number; value: any }>()
return new Proxy(target, {
get(obj, prop: string) {
const original = obj[prop]
if (typeof original !== "function") return original
return async (...args: any[]) => {
const key = `${prop}:${JSON.stringify(args)}`
const hit = cache.get(key)
if (hit && Date.now() - hit.at < ttlMs) return hit.value
const value = await original.apply(obj, args)
cache.set(key, { at: Date.now(), value })
return value
}
},
})
}
const cachedJobsApi = withCache(jobsApi, 30_000)
await cachedJobsApi.list("frontend")
This is the cleanest way to show “add behavior without modifying code” in a take home.
5) Turn “options objects” into a Builder chain that reads in code review
When an API has many optional filters, object literals become unreadable fast.
Before
type Search = {
q?: string
remote?: boolean
minSalary?: number
maxSalary?: number
sort?: "date" | "salary"
limit?: number
}
const params: Search = { q: "react typescript", remote: true, minSalary: 120000, sort: "salary", limit: 20 }
After
export class JobSearchBuilder {
private params: Record<string, string> = {}
keywords(q: string) {
this.params.q = q
return this
}
remoteOnly() {
this.params.remote = "true"
return this
}
salary(min: number, max: number) {
this.params.minSalary = String(min)
this.params.maxSalary = String(max)
return this
}
sortBy(sort: "date" | "salary") {
this.params.sort = sort
return this
}
limit(n: number) {
this.params.limit = String(n)
return this
}
toQueryString() {
return new URLSearchParams(this.params).toString()
}
}
const qs = new JobSearchBuilder()
.keywords("react typescript")
.remoteOnly()
.salary(120000, 180000)
.sortBy("salary")
.limit(20)
.toQueryString()
Interviewers like this because the chain communicates intent, and the implementation isolates parameter formatting.
6) Process paginated APIs with async iterators, not arrays
Loading everything into memory is the default failure mode in interview code. Streaming is the senior move.
Before
async function fetchAllJobs() {
const pages = await Promise.all([1, 2, 3, 4, 5].map((p) => fetch(`/api/jobs?page=${p}`).then((r) => r.json())))
return pages.flatMap((x) => x.jobs)
}
const jobs = await fetchAllJobs()
const top = jobs.filter((j) => j.remote && j.salary > 150000).slice(0, 10)
After
async function* jobsStream(pageSize = 50): AsyncGenerator<{ remote: boolean; salary: number }> {
let page = 1
while (true) {
const res = await fetch(`/api/jobs?page=${page}&limit=${pageSize}`)
const data: { jobs: any[]; totalPages: number } = await res.json()
for (const job of data.jobs) yield job
if (page >= data.totalPages) return
page++
}
}
const top: any[] = []
for await (const job of jobsStream()) {
if (job.remote && job.salary > 150000) top.push(job)
if (top.length === 10) break
}
The break is the win. You stop making network calls as soon as you have enough results.
Closing
Pick one messy function from a take home and apply two of these refactors, Strategy plus typed events is usually enough. Then add one “infrastructure” refactor, Proxy caching or streaming, to show you think beyond the happy path. That is what senior interviews are actually grading.
Top comments (0)