I work with some really smart people. So when I hit a function in our codebase that I can’t follow, my first thought is usually “maybe I’m just not smart enough for this one.” Turns out I’m not alone. Once a function signature grows past a certain number of flags and overrides, nobody understands it. Not the smart people, not the people who wrote it. The combinations get so tangled that the thing can’t be tested, and it definitely can’t be read.
I’ve started calling these Plinko functions.
If you’ve ever seen The Price is Right, you know Plinko. A contestant drops a chip in the top of a big pegboard, it bounces left, right, left, left, right, and lands somewhere at the bottom. Nobody can tell you where. That’s the whole game. The path through the pegs is anybody’s guess.
A Plinko function works the same way. Arguments go in the top, bounce through a board of nested conditionals, and something comes out the bottom. Which path did your call actually take? Who knows. You dropped a chip with 33 parameters into the slot and now you’re watching it rattle around, hoping it lands on the branch you wanted.
Nobody sets out to write one of these. It starts with a perfectly good function:
function getShops(zipCode: string) {
return shopRepository.findAll({ zipCode });
}One job, does it reliably. But then our users with European cars complain, because sometimes we send them to shops that can’t work on their vehicles. Fine, one more param. And since we’re good developers who can sense more filters coming, we give the argument a shape:
type ShopQuery = {
zipCode: string;
make?: Manufacturer;
};
function getShops({ zipCode, make }: ShopQuery) {
if (isEuropean(make)) {
return shopRepository.findAll({ zipCode, specialties: ["european"] });
}
return shopRepository.findAll({ zipCode });
}Still fine. But now the peg board exists, and every team in the org has a chip to drop:
enforceMake.filterByAvailability.useCache, cacheKey.sortBy, limit, offset.Each of these is one small parameter. None of them, on its own, is worth a refactor. That’s exactly how it gets you. A few months of “just one more flag” each sprint and you’re staring at this:
type ShopQuery = {
zipCode: string
make?: Manufacturer
enforceMake?: boolean
coords?: Coords
radius?: number
minRating?: number
prioritizeSponsored?: boolean
throwOnEmpty?: boolean
serviceType?: ServiceType
includeChains?: boolean
excludeChains?: string[]
onlyAcceptsCash?: boolean
maxDistance?: number
sortBy?: "rating" | "distance" | "price"
limit?: number
offset?: number
includeClosedShops?: boolean
useCache?: boolean
cacheKey?: string
ignoreUserPreferences?: boolean
expandResults?: boolean
includeWaitTime?: boolean
returnFullDetails?: boolean
groupByRegion?: boolean
filterByAvailability?: boolean
availabilityDate?: Date
includeServiceHistory?: boolean
onlyVerified?: boolean
returnCount?: boolean
applyDiscount?: boolean
discountCode?: string
validateInput?: boolean
logQuery?: boolean
failSilently?: boolean
}
function getShops(query: ShopQuery) {
// 80+ lines of pegs for your chip to bounce off of
const filter: any = { zipCode: query.zipCode }
// What happens if enforceMake and ignoreUserPreferences are both true?
if (query.enforceMake && !query.ignoreUserPreferences && query.make) {
filter.specialties = getSpecialtiesFromMake(query.make)
}
// What if maxDistance is smaller than radius?
if (query.coords) {
const distance = query.maxDistance !== undefined
? Math.min(query.radius ?? 10, query.maxDistance)
: query.radius ?? 10
filter.geo = { center: query.coords, radius: distance }
} else if (query.maxDistance) {
// This doesn't make sense, but we handle it anyway
filter.maxDistance = query.maxDistance
}
// What if includeChains is false but excludeChains is empty?
if (query.includeChains === false && !query.excludeChains?.length) {
filter.chainFilter = "none"
} else if (query.excludeChains?.length) {
filter.excludeChains = query.excludeChains
}
// What if applyDiscount is true but there's no discountCode?
if (query.applyDiscount && query.discountCode) {
const discount = validateDiscount(query.discountCode)
if (!discount && !query.failSilently) {
throw new InvalidDiscountError()
}
}
let shops = shopRepository.findAll(filter)
if (query.minRating) shops = shops.filter(s => s.rating >= query.minRating)
if (!query.includeClosedShops) shops = shops.filter(s => s.isOpen)
if (query.onlyVerified) shops = shops.filter(s => s.isVerified)
// filterByAvailability without a date: filter? skip? warn? we chose "warn, sometimes"
if (query.filterByAvailability && query.availabilityDate) {
shops = shops.filter(s => hasAvailability(s, query.availabilityDate))
} else if (query.filterByAvailability) {
query.logQuery && console.warn("filterByAvailability set but no date")
}
// Can't sort by distance without coords, so... rating? Sure. Rating.
if (query.sortBy === "distance" && query.coords) {
shops = sortByDistance(shops, query.coords)
} else if (query.sortBy === "price") {
shops = sortByPrice(shops)
} else {
shops = sortByRating(shops)
}
shops = shops.slice(query.offset ?? 0, (query.offset ?? 0) + (query.limit ?? 50))
// If returnCount and returnFullDetails are both true, what shape comes back?
if (query.returnCount && query.returnFullDetails) {
return { count: shops.length, shops }
} else if (query.returnCount) {
return { count: shops.length }
} else if (query.returnFullDetails) {
shops = shops.map(expandShopDetails)
}
if (shops.length === 0 && query.throwOnEmpty && !query.failSilently) {
throw new NotFoundError("No shops found")
}
return shops
}(I trimmed this for the blog. The real ones are worse.)
Some questions you cannot answer without reading the whole function, and maybe not even then:
enforceMake is true but make is undefined?filterByAvailability is true but there’s no availabilityDate, do we filter or silently skip?sortBy: "distance" without coords falls back to rating. Is that documented anywhere? Is it what any caller expects?failSilently and throwOnEmpty both exist? They’re arguing with each other.returnCount and returnFullDetails both be true? What comes back? Is that shape tested?You can’t test this thing exhaustively. If those 33 parameters were merely booleans, that would already be 2^33 combinations, about 8.5 billion. They’re not all booleans, so the real number is much worse. Nobody is writing that test suite. Nobody is even writing a representative slice of it.
And the cost isn’t just testing:
That last one is the killer. The function is unreadable because people kept adding flags, and people keep adding flags because the function is unreadable. Adding one more peg to the board is always easier than fixing the board.
Don’t build one function that handles every scenario. Build a function per scenario. Each one takes only what it needs:
// User searching for shops that can service their vehicle
async function findShopsForVehicle(vehicle: VehicleInfo, location: Location): Promise<Shop[]> {
const specialties = getSpecialtiesFromMake(vehicle.make)
const shops = await shopRepository.findBySpecialties(specialties, location.zipCode)
return shops.filter(s => s.rating >= 4.0).slice(0, 50)
}
// User searching for shops near their current location
async function findShopsNearUser(coords: Coords, radius: number = 10): Promise<Shop[]> {
const shops = await shopRepository.findByGeo(coords, radius)
return sortByDistance(shops.filter(s => s.isOpen), coords)
}
// Admin browsing shops with admin-specific filters
async function findShopsForAdmin(filters: AdminShopFilters): Promise<Shop[]> {
const shops = await shopRepository.findAll(filters)
return filters.verifiedOnly ? shops.filter(s => s.isVerified) : shops
}Each signature documents its own intent. You physically can’t pass conflicting options, because the conflicting options don’t exist. Testing is boring again, which is what you want testing to be.
None of this is a new idea. It’s the single responsibility principle, which we all nod along to and then violate one optional parameter at a time. Every flag on getShops was a new responsibility sneaking in the side door: it wasn’t just “find shops” anymore, it was find shops and cache and paginate and validate discounts and decide its own return shape. SRP violations don’t usually announce themselves with a bad class name. They show up as options?: { ... }.
“But now my controller has an if statement!” Yeah. That’s fine. The controller is the layer that knows what kind of search the user is doing. It’s already holding that context:
app.post("/shops/for-vehicle", async (req, res) => {
res.json(await findShopsForVehicle(req.body.vehicle, req.body.location))
})
app.post("/shops/nearby", async (req, res) => {
res.json(await findShopsNearUser(req.body.coords, req.body.radius))
})The Plinko function was an attempt to push that decision down into the service layer by encoding it in parameters. That’s backwards. The chip already knows where it wants to land before it gets dropped. Let it just go there.
If the separate functions share guts (query building, filtering, sorting), extract those into small helpers the public functions call. Then adding a new search is a few lines, and nobody’s tempted to reach for a flag.
The pattern is easiest to kill when it’s small:
Plinko functions look efficient in the moment. One function, one call site, look how reusable! But it’s a local optimization that everyone pays for globally. Next time you’re about to add a parameter to a function that already has ten, don’t. Write a new function. Your coworkers can’t tell you where the chip lands either. They’ve just stopped admitting it.