A Nakodo campaign looks for one of two things. The usual kind finds creators on YouTube, Instagram and TikTok and asks them to feature a product. On the paid plans it can instead find local businesses: cafes, shops, gyms, salons, the kinds you choose in the places you choose, and ask whether they would like to talk about the product or try it.

From the product's side that is one feature with a switch on it. From the codebase's side it is two vocabularies running through the same screens, and the entire machinery for keeping them apart is 30 lines:

// Who a campaign looks for. Creator campaigns find people who make videos and // posts and ask them to feature the product; business campaigns (Pro and // Business plans) find businesses of a type in chosen places and ask them to // talk or trial it. No imports beyond types, so the schema and client // components can both use this. export const CAMPAIGN_KINDS = ["creators", "businesses"] as const; export type CampaignKind = (typeof CAMPAIGN_KINDS)[number]; export const isCampaignKind = (v: unknown): v is CampaignKind => v === "creators" || v === "businesses"; 

That file is imported by 42 others, and kindWords is called 46 times in 23 of them. The comment's last sentence is the constraint that makes it possible: no imports beyond types, so the Drizzle schema, a Server Action and a "use client" component can all pull from the same place without dragging a database connection into the browser.

The plural that is not a suffix

The first thing anyone reaches for is ${count} ${kind} with an s appended, and the second kind of campaign breaks it immediately. "business" plus "s" is "businesss". So the words are a table, not a transformation:

// The words for what a campaign finds: "3 creators", "Businesses". export function kindWords(kind: CampaignKind) { return kind === "businesses" ? { one: "business", many: "businesses", One: "Business", Many: "Businesses" } : { one: "creator", many: "creators", One: "Creator", Many: "Creators" }; } 

Four forms, because real copy needs all four: a table heading is "Creator", a tab label is "Businesses", a sentence is "3 creators", an error is "Creator not found". The capitalised pair are not a toUpperCase call either, since the day a kind arrives whose display form is not simply its first letter capitalised, a function would be wrong everywhere and a table would be wrong in one place.

Call sites read like this, 46 of them:

const w = kindWords(campaign.kind); ... <TableHead>{w.One}</TableHead> 
if (!row) return fail(`${w.One} not found`); 
Emails are signed &ldquo;{signOff(campaign.name)}&rdquo;. {w.Many}&apos; addresses stay private. 

That last one is a small piece of luck worth noticing: both plurals end in s, so the plural possessive is the same shape for both and {w.Many}' is correct in either vocabulary. If one kind had been "people" that line would have needed its own branch.

Counting pairs with the generic helper:

export const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; 

The default parameter is the suffix rule, which is right for "5 keywords" and "1 campaign". Pass both forms and it is right for businesses too: plural(c.checked, w.one, w.many). The signature lets the common case stay short without making the uncommon case impossible, which is the whole job of a default argument.

Strings that take the noun as an argument

The pattern that surprised me was in the campaign header, where the navigation items are not strings at all:

<span className="block font-medium">{c.label(kindWords(kind).many)}</span> <span className="block text-[13px] text-muted-foreground">{c.text(kindWords(kind).many)}</span> 

Each item carries label and text as functions of the noun. Once a component is kind-aware, the alternative is for every string in it to be an inline ternary, and a config array of ternaries is unreadable in a way that a config array of one-argument functions is not.

The same idea shows up in the outreach settings, where a radio option's title and description are built from the word:

{ value: "manual", title: `Only the ${w.many} I choose`, text: `Select them on the ${w.Many} tab and choose Contact.` }, 

Where parameterisation stops

Not every sentence survives having its noun swapped, and pretending otherwise produces copy that is grammatical and useless. Across the app there are 85 explicit branches on the kind, in 33 files, and the honest ones are where the advice differs rather than the noun.

The email that goes out when a campaign has found nothing good after a day is the clearest example:

 c.kind === "businesses" ? "What helps most is more kinds of business or more places to look, under Businesses and places on the brief, or a looser Ideal business." : "What helps most is broader search keywords on the brief: the words creators use about their own posts, rather than your product's. A looser Ideal creator helps too.", 

There is no noun substitution that turns one of those into the other. They are different pieces of advice about different controls, and they are written out twice on purpose, in the same function, next to each other, where a reviewer can see both.

The rule I have settled on: parameterise the noun, branch on the meaning. If swapping "creator" for "business" produces a true sentence, use kindWords. If it produces a sentence that is true but unhelpful, write both.

The enum, the default and the migration that was not needed

In the database the kind is a text column with the TypeScript list as its constraint:

kind: text({ enum: CAMPAIGN_KINDS }).notNull().default("creators"), 

Forty-one columns in our schema are enums in TypeScript and plain text in Postgres, and this is one of them, for the same reason: adding a value to a Postgres enum type is a migration, adding one to a string union is a type error at every place that needs to change.

The .default("creators") is also the whole migration story. Business campaigns arrived after creator campaigns, and every existing row is a creator campaign, so the default is the backfill. Nothing had to be written, and nothing had to be read twice.

And the type guard at the edge:

export const isCampaignKind = (v: unknown): v is CampaignKind => v === "creators" || v === "businesses"; 

Spelled out rather than CAMPAIGN_KINDS.includes(v as CampaignKind), because the cast inside the includes version is exactly the thing a type guard exists to avoid, and two comparisons are cheaper to read than one clever line.

Which platforms each kind may look on

The last member of the file is the one that stops a kind from offering something it cannot do:

// Where each kind can look. Businesses are found in places listings and read // from their own websites ("web"), or on Instagram and TikTok. export const KIND_PLATFORMS: Record<CampaignKind, readonly Platform[]> = { creators: ["youtube", "instagram", "tiktok"], businesses: ["web", "instagram", "tiktok"], }; 

A Record keyed by the union, so adding a third kind is a compile error here rather than an empty array at runtime. The brief editor intersects this with what the account's plan allows, shows the rest greyed out, and the platform tickboxes come from the result. Two tables, both exhaustive, and the UI is derived.

Why this is worth a post

Because the cheap version of this feature is if (isBusiness) sprinkled through 33 files, and it works, and then six months later one screen says "Creators" on a business campaign and nobody notices until a customer does. Thirty lines with no dependencies, a table of four word forms instead of a suffix rule, and a Record that fails the build when a kind is added: that is the whole defence, and it fits on one screen.

Nakodo runs both kinds, and the plans that include the second one are on the pricing page.