Engineering

A hardcoded dp is a token you did not name

A literal spacing value is a token nobody named. The cost is not inconsistency now but that a redesign cannot distinguish values that happen to match.

MobileByteSensei 10 min read
The authorised measures let into the wall, and the sticks cut to match them
composedesign-systemkotlin

A screen uses sixteen density-independent pixels of padding. So does the next one. By the time the app is reasonably large, that number appears in dozens of files, and every one of them is correct.

The problem is not the value. It is that nothing records which of those sixteens are the same decision.

The case this post is about

One number — 16 — appearing in forty files, and the question a redesign asks of every occurrence:

16.dp                  a number. Why is it 16? Unrecorded.
Spacing.betweenItems   a decision. The value is how it is currently expressed.

Some of those forty sixteens mean the gap between items in a list. Some mean the screen's side inset. Today both are 16, which is why nobody noticed they are different decisions.

Then the design changes the screen inset to 20 and leaves list spacing alone. There is now no way to tell which of the forty to edit, because the information that would distinguish them — what each one meant — was never written down.

This post follows that one number, because the problem is not the literal and the fix is not a constant.

The symptom

The visible version arrives with a redesign. Spacing changes — the standard gap between elements goes from sixteen to twenty — and the work is a search for sixteen across the codebase, followed by a judgement on every result.

Some of those are the standard gap and should change. Some are a deliberate optical adjustment that happens to be the same number and should not. Some are an icon's size, which is unrelated and coincidentally sixteen. The search cannot distinguish them, and neither can anybody reading the call site, because a literal carries no statement about what it means.

Seven sticks that nearly agree, and nothing recording which were meant to

The second thing is the inconsistency that accumulates before any redesign. Somebody needs a gap, the nearest example uses sixteen, and they use sixteen. Somebody else is working from a different reference and uses fourteen. Neither is wrong; nothing records that they were meant to be the same thing, so they drift by small amounts across the app and nobody notices until the screens are seen side by side.

The third is that review cannot catch it. A reviewer looking at a padding of sixteen has no basis for objecting — it matches the surrounding code and looks right. The information needed to say "that should be the standard gap" is not in the diff.

The fourth is what happens on the second platform or the second surface. A design that exists only as literals in one codebase cannot be transferred; it has to be re-derived by looking at screens, which produces values that are close and not identical, and a long tail of small differences nobody can account for.

Why the obvious fix failed

Agree on a spacing scale and be consistent. The scale is the right idea and "be consistent" is the part that fails. A convention that lives in a document and not in the code is enforced by memory, and the person adding the next padding is reading neighbouring code rather than the document.

Search and replace at redesign time. What actually happens, and it is the moment the missing information is most expensive. Every match needs a judgement that could have been recorded once, years earlier, by the person who knew the answer — and is now being made by somebody guessing from context.

A linter that bans numeric literals. Closer and too blunt. Some numbers are genuinely one-off — a specific optical correction, a value derived from a measurement — and a rule that forbids all of them produces either a wave of meaningless named constants or a wave of suppressions.

Extract constants as you go. Better, and without a scheme it produces a different mess: forty constants with names derived from where they were first used, several of which are the same value for the same reason under different names. Naming without a taxonomy is deduplication by coincidence.

The actual mechanism

A token is a name for a decision, and the value is an implementation detail of it.

16.dp                  a number. Why is it 16? Unrecorded.
Spacing.betweenItems   a decision. The value is how it is currently expressed.

The thing that makes this work is not that the value is defined once — deduplication is a side effect. It is that the reason is now attached to the use site. Two paddings that are both the standard gap share a token; two that happen to be sixteen for unrelated reasons do not; and a redesign can change one without touching the other, which is impossible with literals however carefully they are searched.

Each stick checked against the standard rather than against its neighbours

That also makes the review question answerable. "Should this be the standard gap or a deliberate exception" is a question somebody can answer while writing the code, and the answer is recorded in which token they reached for.

The sub-case that determines whether this survives: there has to be a way to say "this one is genuinely bespoke". A system where every value must be a token makes the one-off case painful, and painful cases get worked around — usually by picking the nearest token, which quietly corrupts its meaning. An explicit escape, used rarely and visibly, is what keeps the common path honest.

The one measure on its own bracket, declaring itself as not one of the standards

There is a second-order benefit worth naming: tokens make the design system readable from the code. Somebody can ask what spacing options exist and get an answer by looking at the token definitions, rather than by surveying screens and inferring. The code becomes a statement of the design rather than a sample of it.

The fix

define    a small scale of named spacing decisions
use       tokens at every call site that is making one of those decisions
escape    an explicit, visible form for genuinely bespoke values
gate      literal dimension values in UI code fail the build

The scale, named by decision rather than by size — which is the whole argument, expressed as a file somebody has to edit to disagree with it:

// Spacing.kt — names are PURPOSES. Two can share a value and diverge later.
@Immutable
data class Spacing(
    val betweenItems: Dp = 16.dp,       // the standard gap in a list or column
    val insetScreen: Dp = 16.dp,        // same value today, different decision
    val insetCard: Dp = 12.dp,
    val betweenSections: Dp = 24.dp,
    val touchMin: Dp = 48.dp,           // accessibility floor, not a spacing choice
)

val LocalSpacing = staticCompositionLocalOf { Spacing() }

// Bespoke.kt — the escape, deliberately ugly and greppable
@JvmInline
value class OpticalAdjustment(val value: Dp)
fun optical(dp: Dp, reason: String) = OpticalAdjustment(dp)

betweenItems and insetScreen are both 16 today. Collapsing them because the numbers match is the deduplication-by-coincidence the literals were already doing, one level up — a redesign that moves one and not the other is exactly what this file exists to make possible.

The gate, and what it does on a diff that reaches for a literal:

$ ./gradlew :feature:profile:ktlintCheck
ProfileScreen.kt:31:26  literal dimension in UI code: 16.dp
                        use a Spacing token, or optical(16.dp, reason = "…")
BUILD FAILED in 4s

The gate is what makes the rest hold, and it needs the escape to be tolerable. Without the gate the tokens are a convention and decay like every other convention; without the escape the gate is unusable and gets disabled. The two have to ship together.

The scale should be small. A spacing system with fourteen values is a system where choosing is hard and two people will choose differently for the same situation, which is the original problem with extra steps.

The naming scheme is where most token systems go wrong, and it goes wrong in a specific direction: names that describe the value rather than the decision. A token called spacingMedium has recorded nothing — it is the literal with extra syntax, and when the redesign comes you still cannot tell which mediums are the same medium. A token named for what it is for survives a value change; a token named for its size does not.

That distinction also decides how many tokens you need. Named by size, the set grows with the number of distinct values and every new value needs a new name. Named by purpose, the set grows with the number of distinct decisions, which is much smaller and much more stable — several purposes can share a value today and diverge later without renaming anything.

The convergence is worth dwelling on because it is the thing people find hardest to accept. Two tokens holding the same number look redundant, and the instinct is to collapse them — which is exactly the deduplication-by-coincidence the literals were already doing, reintroduced one level up with better names. A token system that has been tidied until no two values repeat has thrown away the information it was built to hold, and it will fail the next redesign the same way the literals did.

How we proved it

The redesign test is the one that demonstrates the value, and it can be run as a thought experiment before committing to anything: pick a spacing change, and ask whether you could apply it correctly from the code alone. With literals the answer is no — you need judgement per occurrence. With tokens it is a change to one definition.

The gate is mechanically checkable: a scan for numeric dimension literals in UI source either finds some or does not. That makes adoption measurable rather than aspirational, and it makes backsliding visible.

One change applied to an arbitrary subset, and the same change applied to a named group

What I have not measured is consistency before and after. I have not surveyed the spread of spacing values across screens, so I cannot tell you how much drift existed or how much the tokens removed. The argument here is about what becomes possible — a redesign applied from the code — more than about a defect count.

What I have not solved: the migration. Existing literals do not become tokens because a rule exists, and converting them requires exactly the per-occurrence judgement that the tokens were supposed to prevent — done once, by somebody who may not know the original intent. I have done this partially, which means my codebase currently contains both, and the gate applies only to new code.

The partial state is more defensible than it sounds, which surprised me. A gate that applies only to new code stops the problem growing while leaving the existing literals to be converted whenever somebody is in that file for another reason and knows what the value meant. That is slower than a sweep and it puts each judgement in front of the person most likely to get it right, which a sweep cannot do. The cost is that "is this a token" has two answers depending on the file's age, and somebody reading the codebase cannot tell which part they are in without checking the gate's scope.

The second platform is where the argument stops being theoretical. A design expressed as tokens transfers: the same named decisions, resolved to whatever each platform's units are, producing screens that agree because they are derived from one statement. A design expressed as literals cannot transfer at all — it has to be re-read off screens and re-entered, which is how two platforms end up differing by two points in a dozen places that nobody can account for afterwards.

That is the version of this I would lead with for anyone unconvinced by the redesign argument, because it is not hypothetical and it has a date on it. The transfer either is a mechanical mapping or it is a person with a ruler, and which one depends entirely on whether the decisions were named while they were being made.

And a limitation of tokens as a concept: they record that two values are the same decision and not why the decision is what it is. A token named for the standard gap tells you that several places share it; it does not tell you why sixteen was chosen over twenty, which is the question that comes up during the redesign. That rationale still lives outside the code, and I have not found a good home for it.

What this generalizes to

A literal is a decision with its reasoning discarded, and the loss is invisible until something needs to change.

This is not really about spacing. A timeout of thirty seconds, a retry count of three, a page size of twenty, a cache window — each is a decision, each appears as a number, and each loses its meaning at the moment it is typed. Two thirty-second timeouts in different files may be the same policy or may be a coincidence, and nothing in the code distinguishes them.

The test is to imagine the value changing. If you would have to inspect every occurrence and decide individually whether it is this value or merely equal to it, the name is missing — and the right time to add it is while the reasoning is still in somebody's head, which is the moment the literal is first written and the only moment the information is free.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS