Compile-time data contracts: Catch schema mismatches at compile time in Scala 3
What if broken pipelines never launched - because the compiler stopped them?
“If it compiles, contracts align.”
I’ve been working on this problem for a while now. You know how it goes - you’re running a data pipeline, everything’s fine, then someone changes a field name upstream and boom, your job crashes at 2 AM. The on-call engineer (probably you) gets paged, and you spend the next hour figuring out what went wrong.
Once, an upstream team silently renamed a column amount → amt. Our pipeline didn’t crash immediately - it happily wrote nulls for weeks. By the time we caught it, we had to backfill millions of records. That’s the day I realized: runtime validation catches problems late; compile-time contracts catch them earlier.
This post shows you how to catch these issues at compile time. Not at runtime. Not even at test time. At compile time. Your code literally won’t build if schemas drift.
We’ll build this from scratch - first understand how Scala 3 macros work, then write a minimal contract system that proves schema compatibility before your code even runs. I’ll also show the Scala 2 version because, let’s be honest, many teams are still on Scala 2.
All code here runs in a vanilla sbt project. You can grab the working code in github from https://github.com/vim89/compile-time-data-contracts and run it yourself.
Why this matters
Look, I get it. Writing tests is important. But tests are samples. They check what you think might break. The compiler, though? It’s exhaustive for structure. It can prove your schemas match - every field, every type, every time.
Here’s the thing - schemas change all the time in real projects. And when they do:
- Your build should fail early, not your production job
- Keep transformations pure and effects at the edges (so retries don’t duplicate writes)
- Let the compiler do the heavy lifting
Important upfront: This focuses on preventing schema drift within your controlled boundaries - the systems you build, deploy, and version. It doesn’t replace runtime validation for external data sources or third-party APIs. Think of it as catching a large class of schema mismatches during development and deployment, not eliminating all drift everywhere. You’ll still need runtime checks for data coming from systems you don’t control.
What we’ll build
We’re going to build a small compile-time validation system:
- A way to describe case class structure at compile time (we’ll call it “shape”)
- A policy system - Exact, Backward, Forward compatibility modes
- A macro that proves “these two shapes conform under policy P” - or refuses to compile
- A phantom-typed pipeline builder that enforces correct construction order
First, Scala 3 (using inline + quotes), then Scala 2 (using blackbox macros). I’ll point out the differences as we go.
Run the code in github
| |
Part 1 - Scala 3: the mental model (inline + quotes)
Scala 3 macros are different from Scala 2. Better, actually. They’re safer and easier to reason about.
Two main ideas:
inline def: tells the compiler “expand this at compile time”quotes+Expr[T]: gives you a typed AST that you can inspect
sequenceDiagram
actor Dev
participant Compiler
participant Macro as "Conforms.impl"
participant Reflect as "TypeRepr/quotes"
Dev->>Compiler: compile
Compiler->>Macro: splice ${ ... }
Macro->>Reflect: inspect types
Reflect-->>Macro: shapes for Out and Contract
alt conforming
Macro-->>Compiler: emit Conforms[Out,Contract,P]
Compiler-->>Dev: build succeeds
else drift
Macro-->>Compiler: report.errorAndAbort(msg)
Compiler-->>Dev: compile error with drift details
end
Here’s a minimal macro to see how it works:
| |
What’s happening here?
- The
inlinemethod is what users call - The
${ ... }splice calls the implementation - The implementation gets
'x(quoted code) asExpr[Any] - We return
Expr[String]- the AST for what the compiler will embed
Try it:
| |
See how it printed the AST of 1 + 2? That’s the power here - we can inspect code structure at compile time.
Check out the Scala 3 macros overview and best practices for more details.
Part 2 - Describe shapes at compile time (Scala 3)
Now we need to describe case class structure. In real projects, you might use Shapeless, Magnolia, or Scala 3’s Mirror. I’ll show a compact Mirror-based version here because it’s self-contained.
The idea: a typeclass Shape[A] that knows the fields of A.
classDiagram
class Field {
+name: String
+tpe: String
+hasDefault: Boolean
+isOptional: Boolean
}
class Shape {
+fields(): List~Field~
}
Shape "1" --> "*" Field : returns
| |
Yeah, it’s a bit dense. But here’s what it does:
- Uses Scala 3’s Mirror to reflect on case classes at compile time
- Extracts field names and types
- Marks optional fields (those wrapped in
Option) - Returns a
List[Field]describing the structure
flowchart LR
A["Case class A(...)"] --> M["Mirror.Of[A]"]
M --> L["MirroredElemLabels"]
M --> T["MirroredElemTypes"]
L --> Z["zip(labels, types)"]
T --> Z
Z --> S["Shape[A].fields: List[Field]"]
Usage:
| |
Now we can ask the compiler “what fields does this case class have?” at compile time. That’s the foundation.
If you prefer libraries, check out Magnolia - it works great for both Scala 2 and 3.
Part 3 - Policies: Exact / Backward / Forward
Here’s where we define compatibility rules. Think of these as your migration strategies:
| |
Why three policies?
- Exact: Schemas must match exactly. No surprises. Use this for critical paths.
- Backward: Producer can add optional fields. Useful when rolling out new features without breaking consumers.
- Forward: Consumer ignores extra fields. Useful when the producer might send more than you need.
flowchart LR
EX["Policy.Exact"]
BW["Policy.Backward"]
FW["Policy.Forward"]
EX --- BW
EX --- FW
EX:::tight -->|no diffs allowed| OK1["✔ match only"]
BW:::relax -->|allow missing optional in producer\nallow extra fields in producer| OK2["✔ migration adds"]
FW:::relax -->|allow extra fields in consumer side| OK3["✔ tolerant reader"]
classDef tight fill:#eef,stroke:#446;
classDef relax fill:#efe,stroke:#484;
Visual (save/share): A tiny policy lattice - Exact / Backward / Forward.
In practice: start with Exact, relax to Backward/Forward during migrations, then tighten back to Exact once stable.
Part 4 - How this fits with existing tools
Before we dive deeper, you might be thinking: “We already have Avro, Protobuf, schema registries, and Great Expectations. Why add compile-time contracts?”
Fair question. Here’s how they compare:
Schema Registry (Confluent, AWS Glue)
- What it does: Centralized schema storage with version control
- When it helps: Runtime validation across services, schema evolution tracking
- The gap: You find mismatches when the job runs, not when you compile
- Use together: Schema registry for cross-service contracts; compile-time for intra-repo checks
Avro / Protobuf
- What they do: Binary serialization with embedded schemas
- When they help: Network efficiency, strong contracts between services
- The gap: Schema compatibility is checked at runtime or via external tooling
- Use together: Avro/Protobuf for wire format; compile-time checks to ensure your case classes match those schemas
Great Expectations / Deequ
- What they do: Runtime data quality validation (nulls, ranges, distributions)
- When they help: Catching bad data values, statistical anomalies
- The gap: They validate data content, not schema structure at compile time
- Use together: Compile-time for structure; Great Expectations for data quality
The compile-time advantage: Catches drift in your controlled codebase before deployment. If your transform expects field X but the source produces field Y, your build fails - not your production job. Think of it as an additional safety layer for systems you build and version together.
Real scenario: You have Avro schemas in a registry, but your Scala pipeline reads them into case classes. Compile-time contracts verify those case classes match the expected contract before you deploy. If upstream changes the Avro schema, your build breaks - you don’t wait for the job to crash.
Part 5 - Understanding TypeRepr: The compiler’s view of types
Before we dive into the macro, you need to understand TypeRepr - Scala 3’s way of representing types during compilation.
Think of it like this: When you write List[String], the compiler doesn’t just see text. It sees a structured tree:
| |
flowchart TB
A["List[String]"] --> B["AppliedType(List,[String])"]
B --> C["tycon = List"]
B --> D["args = [String]"]
Key TypeRepr operations:
| |
Why we need this: To compare schemas, we need to ask questions like:
- “Is this field an Option?”
- “What’s inside this List?”
- “Does this case class have a primary constructor?”
TypeRepr lets us answer these at compile time.
Part 5 - The TypeInspector pattern: One question at a time
Now let’s build the utilities. Each function answers ONE question about a type. Start simple:
Inspector 1: Is this a case class?
| |
What’s happening:
typeSymbol- Every type has a symbol (like a unique ID)isClassDef- Is it a class? (not a trait, object, etc.)flags.is(Flags.Case)- Does it have thecasemodifier?
Try it mentally:
| |
Inspector 2: What are the type arguments?
| |
What’s happening:
AppliedType- Pattern for generic types likeList[String]orMap[String, Int]args- The type parameters:[String]or[String, Int]
Examples:
| |
Inspector 3: Is this an Option?
| |
What’s happening:
<:<- Subtype check: “Is this type compatible with Option[?]?”Option[?]- The?means “Option of anything”appliedArgs(t).headOption- Get the first (and only) type argument
Examples:
| |
Why we need this: Field-level optionality. When we see age: Option[Int], we need to know:
- The field itself is optional
- The underlying type is
Int
Inspector 4: Is this a sequence?
| |
What’s happening:
- Check if it’s ANY kind of sequence type
- If yes, extract what’s inside (the element type)
Why check multiple types? Because Scala has many collection types:
| |
All these should become SequenceShape(element).
Inspector 5: Is this a Map?
| |
What’s happening:
- Check if it’s a
Map[Something, Something] - Extract BOTH type arguments: key and value
- If Map has wrong number of args, abort (compiler error)
Examples:
| |
Inspector 6: Can this be a Map key?
| |
Why this matters: Maps in Spark StructType only support primitive keys. You can’t have Map[User, String] - User isn’t a primitive.
Examples:
| |
The pattern recap:
Each inspector has ONE job:
- isCaseClass - “Is this a case class?”
- appliedArgs - “What are the generic type arguments?”
- optionArg - “Is this an Option, and what’s inside?”
- seqArg - “Is this a sequence, and what’s the element type?”
- mapArgs - “Is this a Map, and what are key/value types?”
- isAtomicKey - “Can this be a Map key?”
Compose these to answer complex questions. That’s the TypeInspector pattern.
Part 6 - Building shapes recursively: The interpreter pattern
Now we use those inspectors to build a TypeShape - our internal representation of a type’s structure.
This is recursive. For List[Option[User]], we build:
| |
Let me show you the algorithm step by step.
The shape building algorithm:
flowchart TD
T["typeShapeOf(t)"] --> O{"Option?"}
O -- "Yes" --> OI["inner = optionArg(t)"] --> R1["typeShapeOf(inner)"]
O -- "No" --> S{"Sequence?"}
S -- "Yes" --> SE["elem = seqArg(t)"] --> R2["SequenceShape(typeShapeOf(elem))"]
S -- "No" --> M{"Map?"}
M -- "Yes" --> MKV["(k,v) = mapArgs(t)"]
MKV --> K{"isAtomicKey(k)?"}
K -- "No" --> ERR["abort: unsupported key"]
K -- "Yes" --> MV["MapShape(PrimitiveShape(k), typeShapeOf(v))"]
M -- "No" --> CC{"Case class?"}
CC -- "Yes" --> FS["StructShape(collect FieldShape...)"]
CC -- "No" --> P["PrimitiveShape(t.show)"]
| |
The order matters! We check Option first, then sequences, then maps, then case classes, finally primitives.
Walk through an example: List[Option[String]]
flowchart TD
A["List[Option[String]]"] -->|typeShapeOf| B{"Option?"}
B -- "No" --> C{"Sequence?"}
C -- "Yes" --> D["elem = Option[String]"]
D --> E["typeShapeOf(Option[String])"]
E --> F{"Option?"}
F -- "Yes" --> G["inner = String"]
G --> H["typeShapeOf(String)"]
H --> I{"Case class?"}
I -- "No" --> J["PrimitiveShape(String)"]
J --> K["SequenceShape(PrimitiveShape(String))"]
Step 1: Is
List[Option[String]]an Option?- No.
optionArgreturnsNone. - Continue to Step 2.
- No.
Step 2: Is it a sequence?
- Yes!
seqArgreturnsSome(Option[String]). - Build
SequenceShape(typeShapeOf(Option[String])). - Recurse on
Option[String].
- Yes!
Recursion Step 1: Is
Option[String]an Option?- Yes!
optionArgreturnsSome(String). - Recurse on
String.
- Yes!
Recursion Step 2: Is
Stringan Option?- No.
Recursion Step 3: Is
Stringa sequence?- No.
Recursion Step 4: Is
Stringa Map?- No.
Recursion Step 5: Is
Stringa case class?- No.
Recursion Step 6: Fallback to primitive.
- Return
PrimitiveShape("String").
- Return
Unwind the recursion:
| |
Wait, where did the Option go? It got consumed! The typeShapeOf for Option just recurses on the inner type. This is the edge case I mentioned - element-level optionality gets lost.
Handling case classes:
| |
Let’s break this down line by line:
t.typeSymbol- Get the symbol for the case classsym.primaryConstructor- Case classes have a primary constructor.paramSymss.flatten- Get all parameters (flatten handles multiple param lists)- For each parameter
p:p.name- The field name:"id","email", etc.t.memberType(p)- The field’s type as TypeReprp.flags.is(Flags.HasDefault)- Check if it has a default value likeage: Int = 0optionArg(ptpe).fold(...)- Check if the field type is Option[T]:- If yes: extract T, set
isOpt = true - If no: use the type as-is, set
isOpt = false
- If yes: extract T, set
typeShapeOf(uT)- Recurse! Build the shape for the field’s underlying type
Example: case class User(id: Long, email: String, age: Option[Int] = None)
flowchart TD
U["User(id: Long, email: String, age: Option[Int] = None)"]
U --> F1["Field 'id' : Long"]
F1 --> S1["PrimitiveShape(Long)"]
U --> F2["Field 'email' : String"]
F2 --> S2["PrimitiveShape(String)"]
U --> F3["Field 'age' : Option[Int] (default None)"]
F3 --> O1{"Option?"}
O1 -- "Yes" --> A1["inner = Int"]
A1 --> S3["PrimitiveShape(Int)"]
S1 --> COLLECT["Collect fields"]
S2 --> COLLECT
S3 --> COLLECT
COLLECT --> FINAL["StructShape([ id:PrimitiveShape(Long), email:PrimitiveShape(String), age:PrimitiveShape(Int) (optional, default) ])"]
Processing:
Field 1: id
name= “id”ptpe= TypeRepr forLonghasDefault= falseoptionArg(Long)= None →uT = Long,isOpt = falsetypeShapeOf(Long)=PrimitiveShape("Long")- Result:
FieldShape("id", PrimitiveShape("Long"), hasDefault=false, isOptional=false)
Field 2: email
name= “email”ptpe= TypeRepr forStringhasDefault= falseoptionArg(String)= None →uT = String,isOpt = falsetypeShapeOf(String)=PrimitiveShape("String")- Result:
FieldShape("email", PrimitiveShape("String"), hasDefault=false, isOptional=false)
Field 3: age
name= “age”ptpe= TypeRepr forOption[Int]hasDefault= true (has= None)optionArg(Option[Int])= Some(Int) →uT = Int,isOpt = truetypeShapeOf(Int)=PrimitiveShape("Int")- Result:
FieldShape("age", PrimitiveShape("Int"), hasDefault=true, isOptional=true)
Final shape:
1 2 3 4 5StructShape([ FieldShape("id", PrimitiveShape("Long"), false, false), FieldShape("email", PrimitiveShape("String"), false, false), FieldShape("age", PrimitiveShape("Int"), true, true) ])
Why this matters:
Now we have a normalized representation of the case class. We can compare two StructShapes to see if they match, regardless of the original Scala syntax.
The recursive structure handles arbitrarily nested types:
| |
Each recursion builds a piece of the tree. The tree is the schema.
Part 7 - Field vs Element optionality: A critical distinction
Here’s something subtle that most guides miss. The code in github handles two kinds of optionality:
Field-level optionality (Option on the field itself)
| |
The macro detects this and sets isOptional = true on the FieldShape:
| |
Element-level optionality (Option inside a collection)
| |
This creates a nested shape: SequenceShape(OptionShape(PrimitiveShape("String")))
Wait, that’s not in the code in github! Let me check… Actually, the code in github’s typeShapeOf handles this by recursing:
| |
So List[Option[String]] becomes:
seqArgdetects List → extractOption[String]- Recurse on
Option[String]→typeShapeOf(Option[String]) optionArgdetects Option → extractString- Base case:
PrimitiveShape("String")
But the final shape is SequenceShape(PrimitiveShape("String")) - the intermediate Option is consumed!
Why this matters for contracts: When you write:
| |
These should probably NOT conform under Exact. The producer allows null elements, the contract doesn’t. But the current code in github might allow it because the Option gets unwrapped.
This is the kind of edge case you discover when shipping to production. The fix would be to add an OptionalShape wrapper in the model.
Visual (field vs element optionality): Field optionality vs element optionality - they're not the same.
flowchart LR
A["List[Option[String]]"] --> B{"Element optionality?"}
B -- "intended shape" --> C["SequenceShape(OptionalShape(PrimitiveShape(String)))"]
B -- "current code in github\n(consumes Option)" --> D["SequenceShape(PrimitiveShape(String))"]
Part 8 - Compile‑time “conforms” evidence (the full picture)
Now let’s put it all together. Here’s the complete macro from the code in github:
| |
Let me break down what’s happening:
Extract shapes: We summon
Shape[Out]andShape[Contract]and get their field lists. Note the.valueOrAbort- this forces compile-time evaluation.Compare: We build maps of field names → (type, optionality) and find differences.
Apply policy:
- Exact: all differences are errors
- Backward: missing optional fields are OK, extras are OK
- Forward: extras are OK (but missing required fields are not)
Abort or succeed: If there are violations, we call
report.errorAndAbortwith a detailed message showing exactly what’s wrong. Otherwise, we return the evidence.
The beauty here: this runs during compilation. If schemas don’t match, your code won’t compile. Period.
Use it like this:
| |
That’s it. The entire core. In production, you’d want to handle the element optionality issue I mentioned, but the structure stays the same.
Part 9 - Developer ergonomics: What using this actually feels like
Let’s talk about the practical side - what happens when you actually use this in your daily work?
Compile times
Question: Does this slow down compilation?
In practice, not noticeably. The macro runs once per summon[Conforms[...]] call during compilation. For a typical pipeline with 5-10 contract checks, you’re adding maybe 100-200ms to your build. Compare that to the hours you’d spend debugging a production schema mismatch.
Error messages
When schemas drift, you get this:
| |
Clear, actionable, and it stops your build. No cryptic macro errors, no stack traces. Just: “Hey, you have an extra field called segment.”
Integration patterns
With Spark:
| |
With schema registries:
| |
CI/CD integration:
Just run sbt compile. If contracts drift, the build fails. No special tooling needed. Works with Jenkins, GitHub Actions, GitLab CI - anything that runs sbt.
Onboarding
New team members ask: “Why won’t this compile?”
Answer: “Check the error. You’re trying to use CustomerProducer but the contract expects CustomerContract. Either adjust your transform or update the contract.”
That’s it. The compiler guides them. After one or two cases, they get it.
When to use vs skip
Use compile-time contracts when:
- Multiple teams work on the same pipeline codebase
- Schema changes break production regularly
- You want fast feedback (compile vs deploy-and-test)
Skip when:
- One-off scripts or exploratory work
- External APIs you can’t control (use runtime validation)
- Schema is genuinely dynamic (JSON with unknown keys)
Part 10 - Nested types: Maps, Lists, and deep structures
The code in github handles nested types beautifully. Look at this example from CtdcPoc.scala:
| |
What’s happening here:
Nested case classes -
AddressinsideOrderOut. The macro recurses: when it seesOption[Address], it unwraps toAddress, then checks ifAddressis a case class, and recurses again to get its fields.Collections -
List[LineItem]vsSeq[LineItem]. Both match becauseseqArgtreats them equivalently. The macro sees “sequence of LineItem” and recurses onLineItem.Maps -
Map[String, String]inattrs. The macro checks key type (must be atomic), then recurses on value type.Default values -
tags: Seq[String] = Nilhas a default. Under Backward policy, ifOrderOutis missingtags, it’s OK because the contract has a default.
This is why the TypeInspector pattern matters - it handles arbitrary nesting without special cases.
Part 10 - Policy modifiers in two minutes (Ordered, CI, By‑Position)
Sometimes you need stricter comparison. Or different rules. Here are three quick policy extensions:
| |
Try these:
| |
Why these three? Because real systems need them:
- ExactOrdered: For formats where position matters (some CSV parsers, protobuf with field numbers)
- ExactCI: Because someone always uses
UserIdwhen the contract saysuserId - ExactByPosition: For legacy systems that ignore field names entirely
Part 11 - Phantom types: Building impossible-to-misuse APIs
Here’s where things get interesting. The code, also uses phantom types, type-indexing / typestate builder patterns to build a pipeline builder that’s impossible to misuse. What are phantom types? They’re type parameters that don’t appear in the class body but control what you can do with an instance. Think of them as compile-time state machines.
stateDiagram-v2
[*] --> Empty
Empty --> WithSource: addSource
WithSource --> WithTransform: transformAs[Next]
WithTransform --> Complete: addSink[Contract,P] + conforms
Complete --> [*]: build
note right of WithTransform
Evidence constraints:
- S <: WithSource to transform
- S <: WithTransform to sink
- S = Complete to build
end note
From the codebase SparkCore.scala:
| |
How this works:
State transitions as types - Each method returns a new type parameter
S. The compiler tracks which state you’re in.Evidence constraints -
ev: S <:< WithSourcemeans “S must be a subtype of WithSource”. If it’s not, this method doesn’t exist.Compile-time state machine - You literally cannot call methods in the wrong order:
| |
- Contract checking embedded - Notice
ev1: SchemaConforms[CurContract, R, P]inaddSink? That’s where the compile-time contract check happens. The pipeline literally won’t build if schemas drift.
This pattern is called the Phantom Type Builder Pattern. For more details, see:
Why this matters: In production data pipelines, the order of operations matters. Read → Transform → Validate → Write. With phantom types, the compiler enforces this order. You can’t accidentally write before transforming. You can’t transform without a source.
This is the difference between “here’s how macros work” and “here’s how to build production contract systems.”
Part 12 - Schema evolution and versioning: Handling change over time
Here’s the reality: schemas evolve. Fields get added, deprecated, renamed. Teams roll out changes incrementally. The question isn’t whether schemas will change - it’s how you manage those changes without breaking production.
The schema evolution problem
Scenario: You have CustomerV1 running in production. Marketing wants to add a segment field for targeting. But you have 10 pipelines reading CustomerV1. How do you evolve without breaking everything?
Wrong approach: Add segment to CustomerV1, deploy, hope for the best. Result: Some pipelines break because they don’t handle the new field.
Right approach: Version your contracts and use policies to manage transitions.
Versioning strategy
| |
Notice segment is:
- Optional (
Option[String]) - Has a default (
= None)
This makes it backward-compatible - old code can work with new data.
Migration phases
Phase 1: Add the field (Backward policy)
Deploy new producers writing CustomerV2:
| |
Old consumers still read CustomerV1. They ignore the segment field. No breakage.
Phase 2: Migrate consumers
Update each consumer one by one:
| |
The compile-time contract ensures: “Does this consumer handle the new schema?” If you forget to update the case class, it won’t compile.
Phase 3: Deprecate V1
Once all pipelines use CustomerV2:
| |
The compiler warns any remaining V1 usage. After a grace period, delete V1 entirely.
Handling breaking changes
What if you need to rename a field? Say, email → emailAddress?
Non-breaking approach:
| |
Wait, that’s duplication. Better:
| |
Step 2: Migrate producers to write emailAddress
Step 3: Migrate consumers to read emailAddress
Step 4: Remove the fromV2 constructor
At each step, compile-time contracts verify: “Does this transformation produce the expected schema?”
Coexistence during migration
During migration, you have both V2 and V3 running. How to handle?
| |
The contract checks both paths:
| |
Real-world migration: Backward → Exact
Let’s look at a complete migration scenario from the code in github. This is exactly how you’d roll out schema changes in production.
From CtdcPoc.scala:
| |
What’s happening here:
Phase 1: Producer adds field
- Upstream adds
segmentfield toCustomerProducer - Our contract is still
CustomerContract(nosegment) - We use
transformAs[CustomerNext]to explicitly drop the extra field - Then check
CustomerNextconforms toCustomerContractunder Exact
Why this works:
- The transform explicitly declares the output schema (
CustomerNext) - The compiler checks: Does
CustomerNextmatchCustomerContract? Yes (both have id, email, age) - If we later change
CustomerNextby accident, compilation fails
Phase 2: Stabilization Once the migration is done:
| |
This is real-world stuff. The code shows you exactly how to migrate schemas safely.
flowchart TD
P1["Producer adds field 'segment'"] --> T1["transformAs[CustomerNext]\n(drop extras)"]
T1 --> V1["summon Conforms[CustomerNext,CustomerContract,Policy.Exact]"]
V1 --> W1["write sink"]
subgraph Phase 1 ["Migration window"]
direction TB
P1 --> T1 --> V1 --> W1
end
W1 --> P2["Stabilize: sources match contract"]
P2 --> V2["Conforms[CustomerContract,CustomerContract,Policy.Exact]"]
V2 --> W2["noTransform + write"]
Part 13 - Testing compile‑time, for real (copy/paste tests)
How do you test that code fails to compile? With assertDoesNotCompile:
sequenceDiagram
participant Dev
participant CI as "GitHub Actions"
participant SBT as "sbt testOnly *CompileTimeSpec"
Dev->>CI: open PR
CI->>SBT: run compile-fail suite
SBT->>SBT: assertCompiles / assertDoesNotCompile
alt all pass
SBT-->>CI: success
CI-->>Dev: green check
else drift detected
SBT-->>CI: failure with errorAndAbort msg
CI-->>Dev: red X + compiler diff
end
| |
You can wire this into CI:
| |
This is gold. Your CI literally proves that broken schemas can’t be deployed.
Part 14 - Scala 2: how to do the same (and what’s different)
Scala 2 uses def macros with a Context (blackbox/whitebox). The idea is identical, but the implementation uses c.universe trees instead of Expr/quotes.
| |
Key differences:
- Scala 3:
inline+${ ... }splices; Scala 2:macro defwith aContext - Error reporting:
report.errorAndAbort(Scala 3) vsc.abort(Scala 2) - Trees:
Expr[T](Scala 3) vs rawTree(Scala 2) - Type inspection:
TypeRepr(Scala 3) vsType(Scala 2)
Scala 3 macros are safer. The quoted API prevents many common macro bugs. But Scala 2 macros work fine if you’re careful.
For derivation, check out Magnolia - it works across both versions.
Part 15 - Integration with Spark: Runtime defense-in-depth
The code in github also shows how to mirror compile-time policies with Spark’s built-in structural comparators for runtime validation.
From SparkCore.scala:
| |
Two-layer validation strategy:
- Compile-time (macros) - Catches schema drift before deployment
- Runtime (Spark comparators) - Defensive check for external data sources
flowchart TD
F["found: StructType"] --- E["expected: StructType"]
F --> EX["Policy.Exact"] --> C1["equalsIgnoreCaseAndNullability(found,expected)"]
F --> EP["Policy.ExactByPosition"] --> C2["equalsStructurally(found,expected,true)"]
F --> EO["Policy.ExactOrdered"] --> C3["equalsStructurallyByName(found,expected,resolver)"]
For more on Spark’s structural comparators, see the DataType API docs .
Part 16 - A tiny migration playbook you can adopt tomorrow
Here’s how I use these policies in practice:
- Critical paths → Exact. No surprises. If schemas don’t match exactly, builds fail.
- Producer adding optional fields → Backward during rollout. Allows new optional fields on the producer side.
- Consumer that can ignore extras → Forward during rollout. Lets the consumer tolerate extra fields.
- After stabilization → tighten back to Exact.
And remember:
- Keep transformations pure (no side effects inside map/filter)
- Place I/O at edges (read once, write once)
- Make side-effects idempotent (so retries don’t duplicate writes)
Compile-time prevents drift; runtime manages reality. Tests catch behavior, not shapes.
Part 17 - Production patterns: What I learned shipping this
Pattern 1: Organize contracts by version
| |
Pattern 2: Use companion objects for schema caching
| |
Pattern 3: Document policy choices inline
| |
Pattern 4: Test your compile-fail cases
| |
Appendix - Full demo project skeleton (copy & run)
| |
build.sbt (Scala 3)
| |
Run it:
| |
Tip: For Scala 2, add a sibling module with scala‑reflect and copy the S2 file from above.
References
Scala 3 Macros & Metaprogramming
- Rock the JVM - Scala Macros and Metaprogramming (course)
- Rock the JVM - Scala 3 Macros comprehensive guide
- Scala 3 Macros - Official overview
- Scala 3 Reflection guide
- Scala 3 Macro best practices
Apache Spark Integration
- Spark DataType structural comparators
- Spark Dataset implicits (toDF/toDS)
- Spark CSV data source
- Scala 3 encoders for Spark (community)
Phantom Types & Type-Level Programming
- Compile-safe builder pattern using phantom types (Xebia)
- Phantom Types in Scala - Builder Pattern (Rhetorical Musings)
Other Resources
What compile-time contracts can’t catch
Let’s be honest about the boundaries. Compile-time contracts handle a lot, but they’re not magic. Here’s what they can’t do:
| Category | What It Can’t Catch | Why | Solution |
|---|---|---|---|
| External data drift | • Third-party APIs that change without telling you; • Kafka topics from teams you don’t coordinate with; • S3 buckets written by external vendors | You don’t control their build process | Use runtime validation (schema registries, data contracts) |
| Late-arriving schema changes | • Schema registry updated after your deploy; • Database columns added while your job runs | Compile-time happens before deploy | Runtime checks with version monitoring |
| Data quality issues | • Nulls where you expect values (even if field is non-optional); • Out-of-range numbers (age = -5); • Malformed strings (email without @) | Compile-time checks structure, not content | Great Expectations, Deequ, or custom validators |
| Non-breaking additions | • Upstream adds optional field you don’t use yet | This is often fine! Forward policy handles it | Policy-based awareness or stricter monitoring |
| Partial batch failures | • 1000 records match schema, 5 don’t | Compile-time is binary (compiles or doesn’t) | Runtime validation with error tables/quarantine |
The takeaway: Compile-time contracts catch drift within your controlled codebase during development and deployment. For everything else - external sources, runtime changes, data quality - layer in runtime validation. Think defense-in-depth: compile-time as the first gate, runtime as the safety net.
TL;DR
- Compile-time evidence + policy types make schema intent explicit and enforceable
- TypeInspector pattern organizes type inspection into composable utilities
- Phantom types with type-indexing/type-state builders create impossible-to-misuse APIs through compile-time state machines
- Field vs element optionality matters for nested types
- Nested structures (Maps, Lists, case classes) work through recursive shape building
- Real migrations: use Backward during rollout, tighten to Exact after stabilization
- Spark’s comparators provide runtime defense-in-depth
- Schema evolution needs versioning, coexistence strategies, and policy-based transitions
- Developer ergonomics matter: clear errors, fast compile times, easy onboarding
- Compile-time doesn’t replace runtime validation - it complements it for controlled systems
- This catches many drift classes early, but you still need runtime checks for external data
- Compile-time contracts don’t make pipelines unbreakable - they make breakage predictable
