Why architecture matters here
The architectural case for shapeless starts with a specific pain: typeclass instances are per-type, but the logic is often per-shape. A JSON encoder for Person(name: String, age: Int) does the same thing as one for Product(sku: String, price: Int) — encode each field and combine them — yet without derivation you write both by hand. The insight is that the only thing that differs between them is the list of field types, so if you could express 'encode a record given an encoder for each of its fields,' you would write the logic once and it would apply to every record. Shapeless makes that expressible by giving every record a uniform structural type, the HList.
An HList is a heterogeneous list — a list whose elements can have different types, tracked individually in the type. Person maps to String :: Int :: HNil: a String cons an Int cons the empty HNil. This is the generic representation the compiler can recurse over, because it has exactly two shapes — the empty HNil and a head :: tail cons — just like an ordinary list has Nil and cons. A rule for each of those two shapes is enough to handle a record of any length, because any record is just a nesting of conses ending in HNil.
Generic[A] is the type-level bridge that shapeless synthesizes (via a macro) for any case class: it provides to and from functions converting a Person to its HList and back, and it exposes the HList type as a member so the compiler can reason about it. This is the crucial move: it turns the opaque, nominal Person — which the compiler has no generic handle on — into a structural HList that typeclass rules can be written against. Sealed traits get the analogous Coproduct treatment, a type-level 'one of these variants' that mirrors HList's 'all of these fields.'
The engine that assembles instances is recursive implicit resolution, and understanding it is understanding shapeless. You provide the compiler two implicit rules: a base case ('here is an Encoder for HNil' — trivial, encodes nothing) and an inductive case ('given an Encoder for the head type and an Encoder for the tail HList, here is an Encoder for head :: tail'). To derive an Encoder for String :: Int :: HNil, the compiler applies the inductive rule, which needs an Encoder for String (you provide it) and an Encoder for Int :: HNil, which needs an Encoder for Int and an Encoder for HNil — bottoming out at the base case. The whole instance is built by the compiler chaining these rules, entirely at compile time.
Why is compile-time the decisive property? Because it collapses three separate wins into one mechanism. It is fast: the derived instance is ordinary code the compiler emitted, with no reflection, no field lookups, no dynamic dispatch at runtime — it performs like a hand-written encoder. It is safe: if any field's type lacks an instance, the recursive resolution fails and the program does not compile, so 'this type can't be encoded' is a build error, not a production incident. And it is maintainable: add a field to the case class and the derivation automatically accounts for it, because the derivation is structural — there is no hand-written encoder to forget to update. Reflection gives you generality but sacrifices all three; shapeless gives you generality while keeping them.
The architecture: every piece explained
Walk the components. A case class or ADT — Person(name, age), or a sealed trait Shape with cases Circle and Square — is the input the programmer actually writes. Generic[A] is the macro-provided bridge: for a case class it yields an HList representation and the to/from conversions; for a sealed trait it yields a Coproduct. This is the sole point where a macro touches the type; everything downstream is ordinary implicit resolution.
The HList repr — String :: Int :: HNil for Person — is the structural form the typeclass rules operate on. Those rules are the library or user-supplied implicits: the HNil base instance and the head :: tail inductive instance for whatever typeclass is being derived. Together they form a recursive definition of 'an instance for any record.' The compiler's implicit search is the interpreter of that recursion: it matches the target type against the available rules, recurses into the tail, and terminates at HNil, producing a derived instance such as Encoder[Person] assembled from Encoder[String] and Encoder[Int].
LabelledGeneric is the enriched bridge that most real derivations actually need. Plain Generic gives you the field types but not their names — it knows Person is a String and an Int, not that they are called 'name' and 'age.' For a JSON encoder that must emit {"name": ..., "age": ...}, the names are essential. LabelledGeneric encodes each field's name as a singleton type (a type-level literal), attaching it to the field in the HList as a tag, so the derivation can materialize the label as a runtime string while the compiler still tracks it in the type. This is how derived encoders produce correctly-keyed output without any runtime reflection over field names.
The Coproduct path handles sum types — sealed traits and enums. Where an HList is 'all of these fields' (a product), a Coproduct is 'exactly one of these variants' (a sum), written with the :+: operator: Circle :+: Square :+: CNil. The derivation for a Coproduct mirrors the HList one but branches on which variant is present: a base case for CNil (impossible, the empty sum) and an inductive case that handles the head variant or delegates to the tail. This symmetry — products via HList, sums via Coproduct — is what lets shapeless derive instances for arbitrary algebraic data types, not just flat records.
The compile-time result and the guarantee are the payoff. When resolution succeeds, the compiler has synthesized a concrete instance with no reflection anywhere in it — at runtime it is just method calls. When resolution fails — because some field type has no instance — the program does not compile, and the missing capability is caught at build time. That guarantee is the whole reason to prefer shapeless over reflective generic libraries: the set of types your program can handle is exactly the set the compiler could build instances for, and there is no runtime surprise where a type you forgot about blows up in production.
End-to-end flow
Trace deriving a JSON encoder for Person(name: String, age: Int). You ask the compiler for an Encoder[Person] — perhaps by calling deriveEncoder[Person] or simply requesting it as an implicit. There is no hand-written Encoder[Person], so the compiler looks for a derivation rule that can produce one from a Generic (or LabelledGeneric) representation.
The derivation rule fires: it summons LabelledGeneric[Person], which the macro provides, exposing the labelled HList type roughly 'name-tagged String :: age-tagged Int :: HNil.' Now the rule needs an encoder for that HList type, and it delegates to the recursive HList encoder rules. The compiler is now solving a smaller problem: encode a labelled head :: tail.
The inductive HList rule matches. To encode head :: tail it needs an encoder for the head (a String, tagged 'name') and an encoder for the tail (Int :: HNil). The head's encoder is the ordinary Encoder[String] you have in scope, and the label 'name' is recovered from the singleton type as the JSON key. The compiler then recurses on the tail: encode Int :: HNil, which needs Encoder[Int] (in scope) and an encoder for HNil — the base case, which encodes to an empty object. The recursion bottoms out.
The compiler now unwinds the recursion, composing the pieces: HNil contributes nothing, the Int rule adds "age": <int>, the String rule adds "name": <string>, and the LabelledGeneric rule wraps it so it accepts a real Person by calling gen.to to get the HList first. The result is a concrete Encoder[Person] that, given a person, produces {"name": ..., "age": ...} — and it does so with plain field access and string building, no reflection. This synthesized encoder is as fast as one you would have typed by hand.
Now the failure path, which is where the compile-time guarantee shows its value. Suppose you add a field address: Address to Person, but there is no Encoder[Address] in scope. The recursion now requires an Encoder[Address] to encode that field's slot in the HList, and implicit search finds none. The derivation fails and the code does not compile — you learn, at build time, that Address is not encodable, exactly where you can fix it by providing its encoder. A reflective library would have compiled fine and thrown at runtime the first time it serialized a person with an address. That difference — a build error versus a production exception — is the entire argument for compile-time derivation.