When is adding encoding.TextMarshaler to a shared library's value type a commitment you should refuse?
answer
- the interface is adopted without being called
- data outlives the module version
- two pinned versions, two spellings, one day
- explicit Parse and Format keep the choice visible
- additive later, never removable
basics
~20 sRefuse when the type has no single canonical, lossless, human-meaningful text form. Publishing MarshalText pins those exact bytes forever across every consumer's config files, JSON map keys, logs and stored documents, and no module version can take them back.
solid answer
~60 sImplementing `encoding.TextMarshaler` on an exported type is not a formatting convenience, it is publishing a wire format. The moment it ships, `encoding/json`, `encoding/xml`, `flag.TextVar` and every JSON map key across every consumer start producing those exact bytes, and they land in config files, stored documents, log-search queries and database columns you will never see. Nobody can opt out of it selectively, and you cannot change it later: a new module version does not rewrite the data already written, and consumers pin versions independently, so a changed form means two live spellings of the same value. So the bar is: is there one canonical, lossless, human-meaningful spelling that you would defend in five years? If the value has multiple legitimate renderings — money without a currency, a timestamp whose zone matters, an ID with a display form and a storage form — refuse the interface and export explicit `Format` and `Parse` functions instead, so each caller's choice is visible in their code and reviewable in their diff. If you do ship it, decide the resolution deliberately, write the round-trip test, and document the form as API.
code
go · 6 linesvar id AccountID
// All of these are fixed by the exact bytes MarshalText returns:
_, _ = json.Marshal(id) // a value in a config file
_, _ = json.Marshal(map[AccountID]int{}) // an object key in stored documents
flag.TextVar(&id, "account", AccountID{}, "which account")go deeper
Know that the bytes MarshalText returns are what other people's config files and logs will contain, so it is not a private formatting choice you can revise later.
Explain which consumers pick the interface up automatically — JSON values, JSON map keys, XML, flag.TextVar — and why no caller can opt out of it selectively.
Argue the losslessness bar concretely: a form that drops a zone, a currency or a case distinction makes the type unsafe as a map key and silently merges entries, and prove it with a round-trip test.
Own the call and its escape hatch: decide whether one canonical spelling exists, who can overrule you, and what the migration is if it is wrong — a new type or major version, never a quiet change to the existing method.
## What implementing the interface actually publishes A method on an exported type looks like a local decision. `encoding.TextMarshaler` is not, because the standard library and the wider ecosystem probe for it *implicitly*. Once `MarshalText` exists on your type: - `encoding/json` uses it for every value of that type, in every consumer's request bodies and config files. - `encoding/json` uses it for every **map key** of that type, so it appears inside object names in stored documents. - `encoding/xml` uses it for element and attribute text. - `flag.TextVar` makes it a command-line flag format, so it ends up in deployment manifests and runbooks. - Log lines built by encoding a struct carry it, so it ends up in saved log-search queries and alert rules. None of those consumers asked for it and none of them can decline it. That is the property that turns a method into a contract: **implicit adoption at a distance**. ## Why it cannot be taken back Three separate mechanisms make the form permanent. **Data outlives code.** Config files, database columns, message payloads and archived documents already contain the old spelling. Changing `MarshalText` does not migrate them; it only means new writes disagree with old ones, and your `UnmarshalText` now has to accept both forever anyway. **Consumers pin independently.** Under minimal version selection, different services in the same organisation resolve different versions of your module, and nothing forces them to move together. A changed form therefore means two live spellings in production at once, produced by services that must interoperate. **Compatibility is a promise about behaviour, not signatures.** Bumping a minor version does not license a behavioural change; the tooling will happily upgrade consumers into a silent format change with no compile error and no test failure on their side. If the form must really change, the honest mechanism is a **new named type** — or a `/v2` module — that emits the new spelling while the old type keeps emitting the old one, with both accepted on read. ## The decision test Ask three questions before adding the method. 1. **Is there one canonical spelling?** Not a nice one, *the* one. An IP address, an RFC 3339 timestamp, a UUID and a semantic version pass. A monetary amount does not: `12.50`, `1250`, `USD 12.50` and `$12.50` are all defensible, and choosing silently exports one team's assumption to everyone. 2. **Is it lossless?** `UnmarshalText(MarshalText(v)) == v` for every value the type can hold, including the zero value, negatives, and the boundaries. If the form drops a time zone, a currency, a precision or a case distinction, then the type also becomes unsafe as a JSON map key, because two distinct values will collide into one object name and one will vanish on decode with no error. 3. **Is it meaningful to a human?** The whole point of the text pair is that a person reading a config file or a log line understands the value. If the answer is a base64 blob or an opaque number, the case for text is gone and `encoding.BinaryMarshaler` — consumed deliberately by code, not accidentally by every encoder — is the more honest interface. If any answer is no, the alternative is explicit functions: `func (v T) Format(style Style) string` and `func Parse(s string, style Style) (T, error)`. Callers then choose, the choice is visible in their code, and your package is not silently deciding for them. ## Deciding the resolution is part of the decision Even with a clear yes, the details are permanent. For a duration type: does the form carry nanoseconds or does it round to seconds? For a timestamp: is it always UTC, or does it preserve the offset it was constructed with — and if it preserves it, do two values that denote the same instant produce different keys? For an ID: is it case-sensitive? Each of these looks like a formatting detail and behaves like a schema decision. Pick the maximally faithful form, because you can always narrow at a call site later and you can never widen the data already written. ## Who owns the call The package author proposes; the owner of the shared module is the person who can overrule, because they are the one who will field the migration if it is wrong. The useful review question is not "is this format nice" but "which teams will have this string in their stored data within a quarter, and what is our plan if we need it to change". If the answer to the second half is "there is no plan", the format is not ready and the safe move is to ship `Parse`/`Format` first and add the interface once the shape has survived real use. Adding the interface later is a compatible, additive change; removing it is not.
- Your shipped text form turns out to be wrong. What is the least damaging way to change it?Do not change the existing method. Keep `MarshalText` emitting the old spelling and widen `UnmarshalText` to accept both, then introduce a new named type — or a new major module version — that emits the new form, and migrate consumers deliberately. That way no already-written document becomes unreadable and no two pinned versions of your module disagree about what a value means.
- When would you deliberately ship a type with no text form at all?When the type has several legitimate renderings and choosing one exports a guess. Money without a currency, a timestamp whose zone is significant, or an identifier with distinct display and storage forms are all better served by exported `Parse` and `Format` functions taking an explicit style. Each caller's decision then lives in their own code, where a reviewer can see it.
- If the form must be compact and machine-only, what changes?Prefer `encoding.BinaryMarshaler`. The binary pair is consumed deliberately — `encoding/gob`, a cache layer, a record writer — rather than picked up implicitly by JSON, XML, map keys and flags, so its blast radius is the code that opts in. The tradeoff is that nobody can read it in a log line or a config file, which is exactly why it is the wrong choice for anything a human edits.
- How do you keep a published text form honest over time?Treat it as API: document the grammar in the type's doc comment, keep a table-driven round-trip test covering the zero value, boundaries and every historical spelling you still accept, and add a golden-bytes test so any change to the emitted form fails a test rather than silently shipping. A reviewer should be able to see a format change as a red test, not infer it from a diff.
Adding MarshalText is not choosing a font, it is choosing a street address. Everyone writes it down, and you cannot move without finding all the copies.
saying these in an interview costs you the question
- Treats the text form as an internal formatting detail
- Plans to change the form in a minor release
- Assumes consumers upgrade the module in lockstep
- Ships a lossy form on a type used as a map key
- Adds the interface before the spelling has survived real use