Yesterday I added an optional phone number to Nakodo. When a creator or a business says yes to a brand, the app introduces them by email, and the brand can now add a number to go with the address. One field, one afternoon, eleven files in the commit, and a good example of where the work in a small feature actually goes.
The database part: nothing
A campaign's outreach settings are a jsonb column with a TypeScript type on it:
outreach: jsonb().$type<OutreachSettings>(),
So the schema change is a line in a type, in a list of fields that are all optional for the same reason: they were each added after the ones above them.
handoffEmail?: string; // who interested creators are introduced to; the account email if unset
handoffPhone?: string; // a number given with that address in the introduction, as the brand wrote it
No migration, no ALTER TABLE, no deploy ordering to think about, no column that is NULL on two thousand existing rows. The last migration in the repository predates this commit by five days and is untouched by it.
That is the deal with settings in jsonb, and it is a deal, not a free lunch. What you give up is the database's opinion: there is no NOT NULL, no CHECK, no type enforcement at the boundary, and psql will happily store handoffPhone: 42 if some code path asks it to. Which means every guarantee has to be made in the application, deliberately, and the first one is the parser.
The validation part: what counts as a phone number
This is the piece I spent the most time on, because "validate a phone number" is a famous trap. Full E.164 parsing with national conventions is a library and a data set. We are not dialling the number; we are printing it in an email for a human to read. So the rule is: it has to be plausibly a phone number, and otherwise be left exactly as the brand typed it.
// As the brand writes it: digits, the usual separators and a leading +, with
// 7 to 15 digits (the most a number has with its country code).
const handoffPhone = z
.string()
.trim()
.max(30)
.refine((s) => s === "" || (/^\+?[\d\s().-]+$/.test(s) && /^\d{7,15}$/.test(s.replace(/\D/g, ""))))
.transform((s) => s.replace(/\s+/g, " "));
Two regexes doing two different jobs, which is the only way I have found to write this without it being wrong:
- The first one is about characters: an optional leading
+, then digits, spaces, brackets, dots and hyphens. It rejects letters, which is what catches "call the office", "ext 4" and a pasted email address. - The second is about digits: strip everything that is not a digit and count. 15 is the maximum length of an E.164 number including the country code, and 7 is low enough for the shortest real national numbers while still rejecting a year or a house number.
Neither regex reformats the number. +44 7700 900123, (020) 7946 0018 and 02079460018 are all accepted as written, because a number a brand typed in a shape they recognise is a number they will notice is wrong. The only normalisation is collapsing runs of whitespace, which exists for paste artefacts rather than for tidiness.
s === "" is the first appearance of the real theme of this post. Empty has to pass validation, because the field is optional and the form always submits a string.
Five kinds of empty
By the time I was done, "no phone number" existed in five representations, and every one of them is deliberate:
1. "" in the editor's state. React controlled inputs want a string, so the loader converts absent to empty:
handoffPhone: o?.handoffPhone ?? "",
2. Absent in the stored jsonb. The save turns empty back into nothing, so the key is not written at all:
handoffPhone: parsed.data.handoffPhone || undefined,
|| undefined rather than ?? undefined, because the thing coming out of the form is "" and not null. Storing handoffPhone: "" would work and would be wrong: the column would then contain a field that means the same as its own absence, and six months from now somebody writes if ("handoffPhone" in settings) and gets a surprise.
3. null in the thread context. The code that composes an introduction gets a normalised view of the campaign, where every optional setting has already been resolved:
handoffEmail: settings?.handoffEmail?.trim() || row.ownerEmail || null,
handoffPhone: settings?.handoffPhone?.trim() || null,
Note the asymmetry on those two lines, which is the feature in miniature. A missing introduction address has a fallback: the account's own email, because an introduction has to go somewhere. A missing phone number has no fallback, because there is nothing sensible to invent, so the introduction simply does not mention a phone. string | null in the context type, and the composing code never sees "" or undefined.
4. A sentence in the read view. The editor's non-editing state does not leave a blank:
<Fact label="Phone">
{phone.trim() ? (
<span className="font-mono text-[13px]">{phone.trim()}</span>
) : (
<span className="text-muted-foreground">None, so they email you</span>
)}
</Fact>
"None" on its own is a fact with no consequence attached. "None, so they email you" tells the brand what the absence does, which is the only thing they were going to wonder about.
5. A required prop on the summary card. The card on the Outreach tab shows the same setting, through a component where the empty text is not optional:
function Detail({ label, value, empty, mono }: { label: string; value: string | undefined; empty: string; mono?: boolean }) {
<Detail label="Introductions go to" value={settings?.handoffEmail || ownerEmail || ""} empty="Your account email" mono />
<Detail label="Phone in introductions" value={settings?.handoffPhone} empty="None" mono />
empty: string being required rather than empty?: string is a tiny design system decision that pays for itself every time somebody adds a field. You cannot render a Detail without having answered "what does this say when there is nothing", and the compiler asks the question at the moment you are best placed to answer it.
The input itself is three attributes of accessibility and one hint
<Field id="handoffPhone" label="Phone number" optional hint="Given with your email in the introduction. Include the country code.">
<Input id="handoffPhone" type="tel" autoComplete="tel" value={phone} onChange={...} placeholder="+44 7700 900123" />
</Field>
type="tel" for the phone keypad on a mobile, autoComplete="tel" so a browser can fill it, id matched to the Field's htmlFor, and the optional flag rendering the word "(optional)" in the label rather than relying on the absence of an asterisk.
The hint carries the one instruction the validation cannot: include the country code. Our regex accepts 02079460018 because rejecting it would be rude, but a UK number with no +44 in front of it is useless to a creator in Berlin, and the place to say that is next to the box rather than in an error.
Speaking of which, the error message names the shape instead of describing the rule:
: field === "handoffPhone"
? "Enter a phone number with its country code, such as +44 7700 900123, or leave it empty."
"Invalid phone number" would have been three words shorter and would have taught the user nothing. An example is the cheapest specification there is, and "or leave it empty" closes the loop for someone who typed something by accident and wants out.
The two files that are easy to forget
The first is the privacy notice, which lists what the product stores. It is a page in the app, not a PDF somebody emails, so adding a stored field means editing it in the same commit:
- notes, pricing, link and the address introductions go to), your conversations with creators and businesses,
+ notes, pricing, link, the address introductions go to and any phone number they give), your conversations with
+ creators and businesses,
Shipping a field that holds a contact detail and leaving the data inventory stale is the kind of small dishonesty that turns into a real problem exactly once. The privacy notice has a section listing what we store about an account, and that sentence is in it.
The second is the simulation harness. Nakodo's replies to creators are tested by running made-up conversations through the real reply code with nothing sent and nothing written, and those scenarios build a thread context by hand:
handoffEmail: "partnerships@example.com",
+ handoffPhone: null,
A field added to a context type is a field the fixtures have to supply, and TypeScript was the thing that told me so. That is the argument for handoffPhone: string | null instead of handoffPhone?: string in the context type: the optional version compiles everywhere and silently means "no phone" in places that never thought about it, while the explicit null makes every constructor of that object state its answer.
The preserve-on-partial-save detail
One more line, from the function that keeps settings a form did not send:
...(before?.handoffPhone && { handoffPhone: before.handoffPhone }),
The outreach settings object is written by several different editors (the proposal, the details, the replies setup), and each one sends only its own fields. So every save rebuilds the object from what the form sent plus a whitelist of what was already there, spread conditionally so absent stays absent. Add a field and forget this line, and the new setting quietly disappears the next time the brand edits something else on a different page. Ask me how I know what that class of bug feels like.
What the whole thing added up to
Eleven files, and here they are: the schema type, the validator and its server action, the page loader, the editor, the summary card, the thread context, the code that composes the introduction, the reply classifier and its test (which got some unrelated tightening in the same commit), a scenario in the simulation script, and the privacy notice. Sixty-nine lines added, eleven removed, and zero SQL.
The ratio of "places that must agree about empty" to "lines that implement the feature" is roughly five to one, which is why a one-field change is never a one-line change, and why the useful question in review is not "where is the field" but "what does every surface say when it is not there".
If you want to see the behaviour from the other side, how Nakodo works describes the introduction this number goes into, and our guide to following up with a creator is the human version of the stage where a yes turns into a conversation between two people.
Top comments (0)