A Go build links both example.com/lib and example.com/lib/v2 — why do their types refuse to interoperate?
answer
- two paths, therefore two packages
- type identity includes the import path
- package-level state exists twice
- sentinel errors stop matching
- pick a seam where nothing crosses
basics
~20 sBecause a Go type's identity includes the import path of the package that defines it. Two majors are two module paths, so lib.Client and lib/v2.Client are unrelated types, with separate package-level state and separate sentinel errors.
solid answer
~50 sRequiring both majors is legal and is how incremental migration works — but the compiler sees two unrelated packages. A named type is identified by its defining package's import path, so a `*lib.Client` is not assignable to a parameter of type `*lib/v2.Client`, and the error message naming both full module paths is the tell that you have two modules rather than a typo. The knock-on effects are worse than the assignment: each major has its own package-level state, so an init-registered registry or a package-level default exists twice; `errors.Is` against v1's sentinel fails for an error produced by v2; and an interface defined in v1 is a different interface from the identically-shaped one in v2. Coexistence is safe only where the two never meet, so pick migration seams at package boundaries where no value crosses, or make one major a thin wrapper over the other so there is one implementation and one set of globals.
code
text · 2 linescannot use cfg (variable of type *"example.com/lib".Config)
as *"example.com/lib/v2".Config value in argument to v2.Newgo deeper
Know that requiring two majors of the same library is legal and that their types are separate. If the compiler names two paths differing only by a /v2, it is telling you two modules are in the build.
Explain type identity: a defined type belongs to its package, and the package is its import path, so identical-looking structs from two majors are unrelated. Note that an explicit conversion may compile but is a shim, not a fix.
Show migration judgment — choose seams where no library value crosses, check for package-level state that would be duplicated before going gradual, and own the adapter with a deletion date rather than scattering conversions.
Decide whether the organisation runs a gradual migration at all, given duplicated global state and a widening seam. Set an end date for the two-major period and say who pays for the adapter and its removal.
## Why the compiler says no Go's type identity rule is simple and absolute: a **defined type** is identified by its name *and* the package that defines it, and a package is identified by its import path. `example.com/lib` and `example.com/lib/v2` are different import paths, so `lib.Client` and `v2.Client` are different types, no matter how identical their fields are. Assigning one to the other, or passing one to a function expecting the other, does not compile — and the message names both types by their full paths, which is exactly how you tell this apart from an ordinary typo. Seeing two paths that differ only by a `/v2` in a single error is the diagnosis. This is not a defect of module versioning; it is the same rule that stops you passing a `mypkg.ID` where an `otherpkg.ID` is expected. Semantic import versioning simply makes the two packages *look* like the same package to a human reader. ## What actually bites in production The assignment error is the friendly case, because it is a compile failure with a clear message. The problems that survive compilation are the ones that cost a night: **Duplicated package-level state.** Each major is its own package, so each has its own package-level variables and its own `init` functions. A registry that drivers or plugins register into now exists twice, and a plugin that registered with v1 is invisible to a lookup through v2. A package-level default client, a cached configuration, a `sync.Once` guarding one-time setup — all doubled. This is the single most common way two-major coexistence goes wrong, and it produces "it works locally, it is empty in production" behaviour rather than a build error. **Sentinel errors and error matching.** `errors.Is(err, lib.ErrNotFound)` returns false for an error produced by the v2 package, because v2 has its own distinct `ErrNotFound` value. Error handling code written against v1 silently stops recognising failures once part of the system moves. The same holds for `errors.As` with a v1 concrete error type: a v2 error will never match it. **Interfaces do not bridge automatically either.** An interface type declared in v1 is a different type from the identically-shaped one in v2, though a concrete type satisfies both if its method signatures mention no version-specific types. The moment a method takes or returns `lib.Options`, the two interfaces genuinely diverge and no value satisfies both. **Binary size and initialisation cost.** Both trees are compiled in, both sets of `init` functions run, and any expensive package-level setup happens twice. ## Can you convert between them? Sometimes, and it is a trap worth naming. Two defined types with *identical underlying types* can be converted explicitly, so `v2.Config(oldCfg)` may compile when the structs happen to match field for field. That is fragile: it silently stops compiling the moment either major changes a field, and it does nothing for interfaces, methods or errors. Treat an explicit conversion as a short-lived shim, not a strategy. ## How to run the migration so this does not hurt 1. **Choose a seam where no value crosses.** Migrate whole packages, not individual call sites, and pick boundaries where the interface between the migrated and unmigrated code is your own types rather than the library's. A background job, a single handler, or one storage adapter is usually a clean unit. 2. **Write the adapter deliberately if a value must cross.** One small package that converts v1 types to v2 types in one direction, owned and tested, beats conversions scattered through the codebase. Give it a deletion date. 3. **Watch for shared global state before you start.** If the library registers drivers, keeps a package-level default, or reads process-wide configuration in `init`, coexistence is much riskier than the type errors suggest — that state will exist twice with no warning. In that case migrate in one change rather than gradually. 4. **As the library author, consider making one major a wrapper.** If v1 is reimplemented as a thin shim over v2, then even a program requiring both paths links one implementation and one set of globals. It costs the author a release and saves every consumer the hard part. 5. **Finish.** Two majors in a build should be a state you pass through, not one you live in. Track the remaining v1 imports as work, because every day both are present is a day the seam can grow. ## What a strong answer sounds like Name the rule (type identity includes the import path), name the surprise (duplicated package-level state and sentinel errors, which compile fine), and describe a migration seam. Saying "you cannot have two majors in one build" is wrong and gives away that you have never done the migration — the whole point of the `/vN` path is that you can.
- Which failure from having both majors in a build does not show up at compile time?Duplicated package-level state. Each major runs its own `init` functions and owns its own package variables, so a driver registry, a package-level default or a `sync.Once` exists twice. Registration through one major is invisible to the other, and the program builds and starts cleanly before behaving as if the setup never happened.
- Why does errors.Is against a v1 sentinel stop matching once part of the system uses v2?`errors.Is` compares against a specific value, and v2 declares its own `ErrNotFound` distinct from v1's. An error produced by the v2 package therefore never matches the v1 sentinel, so a handler written against v1 silently falls through to its default branch instead of recognising the case.
- As the library author, how can you make coexistence safe for consumers?Reimplement the older major as a thin wrapper over the newer one and release it. A program that requires both paths then links one real implementation and one set of package-level variables, so registries and defaults are shared and the only remaining work at a seam is type conversion. It costs you one release and removes the worst failure mode for everyone.
- When would you refuse a gradual migration and move everything at once?When the library keeps process-wide state — a driver registry, a package-level default, metrics registration in `init` — because coexistence duplicates it with no compile error. Also when the library's types appear in your own exported API, since every consumer of yours would then need conversions at their boundary as well.
saying these in an interview costs you the question
- Says two majors cannot be in one build
- Blames the error on a bad go.mod or missing tag
- Assumes identical structs are assignable across majors
- Expects package-level registries to be shared
- Trusts errors.Is to match a sentinel across majors