DEV Community

JSGuruJobs
JSGuruJobs

Posted on

6 TypeScript Refactors That Turn Interview Code Into Production Architecture

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" } })
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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" })
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 })
  }
}
Enter fullscreen mode Exit fullscreen mode

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 })
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)