Skip to content

DRAFT Scala 3: implement the compile-time refinement macros - #1453

Open
lgmyrek wants to merge 3 commits into
fthomas:masterfrom
lgmyrek:feat/hearth
Open

DRAFT Scala 3: implement the compile-time refinement macros#1453
lgmyrek wants to merge 3 commits into
fthomas:masterfrom
lgmyrek:feat/hearth

Conversation

@lgmyrek

@lgmyrek lgmyrek commented Jul 15, 2026

Copy link
Copy Markdown

DRAFT Scala 3: implement the compile-time refinement macros (via Hearth)

Summary

The Scala 3 build of refined-core has so far shipped without the compile-time
macro layer — auto.autoRefineV/autoInfer, refineMV, RefType.refineM/applyRefM,
and the compile-checked RefinedTypeOps.apply didn't exist on Scala 3. This PR
implements them, porting the behavior of the Scala 2 scala-reflect macros to Scala 3
with Hearth 0.4.1 — using its Expr.semiEval
compile-time evaluator + the cross-quotes DSL rather than hand-written scala.quoted
macros.

All changes are confined to scala-3.0+ sources; Scala 2.12/2.13 are untouched.

What's added

Public API — Scala 3 now matches Scala 2 apart from the @@ (shapeless tag) carrier:

  • auto.autoRefineV, auto.autoInfer — implicit compile-time conversions
  • refineMV[P](t) — package-level
  • RefType[F].refineM[P](t)
  • RefType.applyRefM[FTP](t)
  • RefinedTypeOps.applyobject PosInt extends RefinedTypeOps[PosInt, Int]; PosInt(5)

Internals:

  • macros/Macros.scala, macros/RefinedMacro.scala — the Hearth-based macro impls
    (validate via semiEval; emit Refined.unsafeApply / unsafeWrap / upcast)
  • macros/RefinedTypeOpsM.scala, internal/RefineMPartiallyApplied.scala,
    internal/ApplyRefMPartiallyApplied.scala — the partially-applied builders
  • internal/WitnessAs.scalasingletonWitnessAs routes on whether the base type B
    is a singleton (see difference Do not use Scala's collection hierarchy for collection predicates #5)
  • build.sbt — Hearth dependency + -Xplugin wiring for the cross-quotes compiler
    plugin (JVM-published, reused across platforms, pulled in Provided)

Tests: all 20 scala-3.0- specs ported to scala-3.0+, with a Scala 3 illTyped shim
(test/ScalaVersionSpecific.scala) built on scala.compiletime.testing.typeCheckErrors.

Validation

Full Scala 3 platform matrix, green on stable Hearth 0.4.1:

Platform Command Result
JVM validateJVM30 (clean + all modules) core 496, cats 237, + scalacheck/scodec/scopt/…
JS validateJS30 core 491, cats 203, scalacheck 22
Native validateNative30 core 491, cats 203, scalacheck 22

Scala 2.12 / 2.13 coreJVM/Test/compile: unchanged and green. This confirms the
cross-quotes/semiEval macros run at compile time across JVM, JS, and Native.

Differences from the Scala 2 implementation

  1. Macro engine. Scala 2 uses scala-reflect blackbox macros with literal matchers
    (Literal(Constant(_)), BigDecimalMatcher, …). Scala 3 uses Hearth's semiEval,
    which reflectively evaluates the expression tree at compile time. This is strictly
    more capable — it evaluates List(1,2,3), BigInt("1"), method calls, etc. So a
    few values Scala 2 rejected as "not a compile-time constant" now succeed at
    compile time (the ported BigLiterals/RefineM "non-literal" cases had to switch to
    genuinely non-evaluable inputs to still fail).

  2. No @@ (shapeless tag) carrier. No tagRefType on Scala 3, so refineMT,
    refineMF, and the @@ variants of applyRefM/RefinedTypeOps don't exist;
    refineMV/refineM/applyRefM target Refined only.

  3. Refined is an opaque type (erases to its base type), unlike the Scala 2 value
    class: Refined.unsafeApply(1).equals(1) is true, there is no .copy, etc.
    (RefinedSpec.equals adjusted accordingly.)

  4. hypotheticalSyllogism (transitivity) is omitted from the Scala 3 Inference
    rules — see Concerns. Two-hop chains like Last[P] ==> NonEmpty (via Exists[P])
    are therefore not derivable on Scala 3.

  5. Equal[SomeObject.type] (object-singleton predicates) works via ValueOf rather
    than constValue in WitnessAs. constValue rejects a non-literal singleton ("not a
    constant type"); ValueOf accepts both. singletonWitnessAs routes on whether the
    base B has a ValueOf: object singletons take the ValueOf branch (exercised only
    at runtime, via isValid), while everything else stays on constValue (which
    semiEval reduces natively). Keeping ValueOf off the compile-time path is why a
    stable Hearth release suffices.

  6. Cosmetic: some Inference.show strings differ (e.g. greaterInferenceInt vs
    greaterInference).

Not a difference: the point-free/curried builder forms behave the same as Scala 2 —
refineMV(t), refineMV[P][T](t), refineV(t), RefType[Refined].refineM(t) all infer
the predicate P from the expected type (a case-class field, a val ascription, a
method parameter). Only a fully-standalone val x = refineMV(5) (no expected type) needs
an explicit refineMV[P](5) — exactly as on Scala 2. The ported specs mirror this (bare
where the original was bare, explicit only where there is no expected type, e.g. inside
illTyped("…") snippets).

Concerns / open items (please review)

  1. 🟠 hypotheticalSyllogism removal weakens inference for all Scala 3 users, not
    just tests. Its intermediate B occurs only in the premises, so resolving a goal
    through it spawns an ambiguous free-RHS subgoal A ==> ?B (unifying with
    minimalTautology, disjunctionIntroduction{L,R}, …) that Scala 3's implicit search
    reports as an ambiguity aborting the whole search — including otherwise-derivable goals
    such as Size[Interval.Closed[1,n]] ==> NonEmpty. Dropping it keeps the common
    single-step and conjunction-elimination inferences working, at the cost of transitive
    two-hop chains. Priority-tweaking was tried and is whack-a-mole; a proper fix likely
    needs goal-directed resolution. (Whether Scala 3.7's given-prioritization change helps
    is unclear — likely not, since the root cause is an under-constrained search, not a
    priority tie.)

  2. 🟠 New third-party macro-engine dependency. Hearth becomes a (necessary) transitive
    dependency of refined-core on Scala 3 — downstream users get it on their compile
    classpath for macro expansion. cross-quotes is a compiler plugin, wired as a
    Provided dep via -Xplugin (only needed to compile refined-core itself).

  3. 🟡 Test diagnostics via the auto-conversion path. A real compile of
    val x: Char Refined Equal['0'] = '1' correctly reports Predicate failed: (1 == 0).
    But scala.compiletime.testing.typeCheckErrors (the only in-process compile-check API,
    used by the test shim) suppresses a macro abort raised while trying an implicit
    conversion and exposes only the resulting type mismatch. Net: the "Predicate failed"
    message is asserted on direct refineMV[…] calls (RefineMSpec), while auto*
    conversion negatives assert only rejection (type mismatch). Real user-facing
    diagnostics are unaffected/good.

  4. 🟡 -Xplugin jar resolution picks the first compile-classpath entry whose path
    contains hearth-cross-quotes, and sys.errors if absent. Robust today; slightly
    fragile against future artifacts with a matching name.

Intentionally not ported

@@-tag cases (refineMT, RefTypeSpecTag, Min/Max @@, autoRefineT,
refineT/refineMT syntax), OneOf (no Validate on Scala 3), the <:!< subtyping
test, and the transitive Last ==> NonEmpty inference — each documented in-file with the
reason.

Credits

All credits to @kubuszok for https://github.com/kubuszok/hearth and https://github.com/kubuszok/refined-compat, I am but a man with claude

@lgmyrek
lgmyrek marked this pull request as draft July 15, 2026 15:31
@mergify

mergify Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@lgmyrek

lgmyrek commented Jul 15, 2026

Copy link
Copy Markdown
Author

@fthomas RFC

@lgmyrek lgmyrek changed the title Feat/hearth DRAFT Scala 3: implement the compile-time refinement macros Jul 15, 2026
@lgmyrek
lgmyrek marked this pull request as ready for review July 24, 2026 11:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant