skip to content

With `type Seconds float64` and `type Millis float64`, why does `Millis(s)` compile, and how do you stop unit mix-ups?

level: seniorimportance: should knowfreq 38%

answer

  1. the compiler checks names, not meaning
  2. conversion relabels, it never scales
  3. one relation is wider than the other
  4. make the wrong call impossible to write
  5. unexported fields do not cross packages

basics

~20 s

Conversion between two defined types with the same underlying type is a pure relabel: 30 seconds becomes 30 millis, unscaled. Defined types block accidental assignment, never a written conversion. Put the scaling in named methods.

solid answer

~50 s

The assignability rule stops `timeout(s)` because `Seconds` and `Millis` are two named types, and that is the protection people think they bought. But conversion is the wider relation: `T(v)` is permitted whenever the two types have identical underlying types, however many of them are named, and it does nothing but relabel the bits. So `Millis(s)` compiles and ships a thousand-fold error. The toolchain will not help — `go vet` has no analyzer for unit semantics, and there is no run-time check. The fixes are all about making the wrong thing unwritable: expose scaling methods like `func (s Seconds) Millis() Millis`, keep every raw conversion inside the one package that defines the types, use `time.Duration` for durations so arithmetic carries the unit, and where it truly matters wrap the number in a struct with an unexported field so callers in other packages cannot convert at all.

code

go · 17 lines
go
type Seconds float64
type Millis float64

func (s Seconds) Millis() Millis { return Millis(s * 1000) }

func timeout(ms Millis) { /* ... */ }

func call(s Seconds) {
	// compile error: Seconds is not assignable to Millis
	// timeout(s)

	// compiles, and is wrong: 30 seconds arrives as 30 millis
	timeout(Millis(s))

	// the only form that scales
	timeout(s.Millis())
}

go deeper

for a junior

Know that writing T(v) between two types with the same underlying type just relabels the value: no arithmetic happens, so converting seconds to a millisecond type does not multiply anything.

for a middle

Explain the asymmetry between the two relations — assignability needs one side unnamed, convertibility does not — and why that means a defined type blocks accidents but not a written conversion.

for a senior

Demonstrate the production judgment: name what actually catches this (scaling methods, conversions confined to one package, time.Duration) and what does not (the compiler, go vet, tests that build their own values).

for a principal

Own where the expensive defence goes. Struct wrappers with unexported fields buy real impossibility but cost arithmetic ergonomics across every caller, so spend them on money and physical units, not on every identifier in the codebase.

## Why the conversion compiles Go's two relations do different jobs, and the gap between them is where this bug lives. **Assignability** requires identical underlying types *and* that at least one of the two types is not a named type. `Seconds` and `Millis` are both defined types, therefore both named, so `var m Millis = s` is rejected and so is passing a `Seconds` to a parameter of type `Millis`. Good. **Convertibility** is wider. `T(x)` is permitted when `x` is assignable to `T`, **or** when the two types have identical underlying types — with no named/unnamed condition at all. Both are `float64` underneath, so `Millis(s)` is legal. And the conversion between two types with the same underlying type is a *reinterpretation*: no scaling, no rounding, no check, no run-time cost. The number 30 stays 30 and only the label changes. So a defined type gives you a barrier against **accidental** mixing and no barrier whatsoever against a **written** conversion. That distinction is the whole answer, and it is why `type Seconds float64` is a review aid rather than a safety mechanism. ## Why nothing catches it - The compiler cannot: as far as the type system is concerned the conversion is exactly what it was asked for. - `go vet` has no analyzer for unit semantics; there is nothing to enable. - There is no run-time signal. The value is plausible, the program does not panic, and a 30-second timeout that became 30 milliseconds shows up later as retries, flapping health checks or a mysteriously high error rate. - Tests usually miss it, because the test constructs its own value in whichever unit the code under test expects. The conversion is also *attractive* at exactly the wrong moment. Someone deleting a deprecated code path finds a call that no longer compiles because the replacement function takes the other unit, and the fastest way to make the build green is to wrap the argument in the target type. The diff looks like a type fix. It is a semantic change. ## Making the wrong thing hard to write **1. Give the scaling a name, and make it the only public path.** ```go func (s Seconds) Millis() Millis { return Millis(s * 1000) } ``` Now `timeout(s.Millis())` and `timeout(Millis(s))` sit next to each other in review and read completely differently. This is the cheapest change and catches most cases. **2. Keep raw conversions in one package.** Every `Millis(...)` on a value that is not already a plain number should live in the file that defines the types. Elsewhere, call the method. A grep for the conversion syntax then has a small, auditable answer. **3. Prefer `time.Duration` for durations.** It is the standard library's unit type, it carries nanoseconds, and its arithmetic and constants (`30*time.Second`) make the unit visible at the construction site. Two teams inventing `Seconds` and `Millis` in parallel is itself the bug; one shared duration type removes the conversion entirely. **4. Where it genuinely matters, remove convertibility.** Wrap the number: ```go type Millis struct{ v float64 } ``` A struct's identity includes its field names, and an unexported field name is qualified by its declaring package. So from any other package there is no type with an identical underlying type, and `Millis(x)` simply does not compile — the only way in is the constructor you export. The price is that arithmetic now goes through methods and the value stops printing as a bare number. Spend that price on money, on physical units in a control path, and on identifiers that must never cross — not on everything. ## The same trap without numbers It is not a float64 problem. With ```go type UserID string type OrderID string ``` `OrderID(u)` compiles just as silently, and produces a lookup against the wrong table with a well-formed UUID that exists nowhere. The failure is quieter than the duration case because the value looks exactly right in a log line. The same three defences apply, and the struct-wrapper defence is the one that actually holds when the ids are opaque strings that no reviewer can distinguish by eye. ## A related silent conversion worth knowing Conversions between *different* numeric underlying types do not reinterpret — they convert, and they can lose data without a word. `int(f)` on a `float64` truncates toward zero; `int8(300)` on a variable wraps. (A constant is different: `int(3.9)` written literally is a compile error, because the untyped constant is not representable.) In review, treat any numeric conversion on a non-constant value as a place where a comment should explain why the loss is acceptable. ## What to say in an interview Name the asymmetry first — assignability blocks it, convertibility does not, and conversion between identical underlying types is a relabel. Then say what you do about it, in the order of cost: scaling methods, conversions confined to one package, `time.Duration` where it fits, struct wrappers where the incident would be expensive. Finish with the review heuristic: a conversion appearing in a diff whose purpose was to make the build compile is the single highest-yield thing to question.

  • If `f` is a `float64` holding 3.9, what does `int(f)` give, and does the compiler warn?
    It gives 3 — conversion between different numeric types truncates toward zero, silently, with no diagnostic. Writing `int(3.9)` on the literal constant is different: an untyped constant must be representable in the target type, so that one is a compile error. The lesson is that a numeric conversion on a variable is where data quietly disappears.
  • You are deleting a deprecated code path and a call site stops compiling because the replacement takes the other unit type. What is the risky fix?
    Wrapping the argument in the new type to make the build green. It looks like a mechanical type fix in the diff and is a semantic change of a thousand times. The correct move is to call the scaling method, or to convert the caller's own value to the right unit at its source and let the compile error stand until you have.
  • Does the same silent reinterpretation happen with two defined string types like `UserID` and `OrderID`?
    Yes, identically: both have underlying type `string`, so `OrderID(u)` compiles and relabels an opaque id. It is worse in practice because the value still looks well formed in logs and in the database query, so the mistake surfaces as a missing row rather than a type error.
  • How does wrapping the value in a struct with an unexported field stop the conversion?
    Struct type identity includes field names, and an unexported name is qualified by the package that declares it. From outside that package no other struct type has an identical underlying type, so there is nothing to convert from and `Millis(x)` does not compile. Callers must go through the exported constructor, which is where the scaling lives.

saying these in an interview costs you the question

  • Says the compiler catches the mix-up because the two types differ
  • Thinks T(v) between defined numeric types applies a scaling factor
  • Believes go vet flags a unit mismatch
  • Treats an added conversion in a diff as a harmless type fix
  • Claims a defined type over float64 makes the value impossible to misuse