Iron: refined types for Scala 3 with inline constraints
Strong type constraints for Scala
At a glance
- What is it?
- Iron is a Scala 3 library that attaches runtime-checkable constraints to types, so a Double that must be positive is a different type from a Double that may be negative, verified at compile time by inline macros or explicitly at runtime. It suits functional Scala teams that want the value restriction and parsing libraries to talk to their own domain constraints, and it is not a substitute for modelling state transitions.
- Who is it for?
- Iron fits a Scala 3 team whose domain has invariants that the type system should carry, such as non-negative counts or validated identifiers, since a refined type is a subtype of its unrefined version and can be added or removed without rewriting call sites, and the error messages explain which constraint failed.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly Scala, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
Constraints attached to a type, not to a value
Iron is described as a lightweight library for refined types in Scala 3, and the one-line summary of what it does is that it enables attaching constraints and assertions to types, to enforce properties and forbid invalid values.
The distinction from a validation library is the point. A validation library checks a value and hands you back an Either. Iron changes the type of the value itself, so the illegal state cannot be represented in the first place. The readme's example is the canonical one:
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.numeric.*
def log(x: Double :| Positive): Double =
Math.log(x) //Used like a normal `Double`
log(1.0) //Automatically verified at compile time.
log(-1.0) //Compile-time error: Should be strictly positiveThe syntax is the context bound. Double :| Positive reads as a Double refined by the Positive constraint, and the function body uses x as an ordinary Double with no unwrapping, no wrapper class and no .value. Inside the function, the compiler knows the value is positive, which is what makes the library worth the trouble: Math.log has a precondition and the refinement discharges it.
Two design claims in the readme explain why this is usable rather than merely interesting. Iron types are subtypes of their unrefined versions, so you can add or remove them easily, meaning a function expecting a plain Double accepts a refined one, and a refined value can be passed to code that does not care. And the readme stresses that there is no black magic: it uses Scala 3's inline, types and restricted macros for consistent behaviour and rules, with no unexpected behaviour.
The last point matters more than it might appear. Scala 3's `inline` and `transparent inline` give a library a controlled way to run code at compile time inside the caller's scope, and the restricted macro model means the library cannot inspect arbitrary things it should not. The claim is that the mechanism is standard Scala 3 rather than a bespoke compiler plugin.
The library is Apache-2.0 licensed, the homepage is a microsite at iltotore.github.io/iron/docs, and the released versions are v3.4.1 from 2026-10-19, v3.4.0 from 2026-10-12 and v3.4.0-RC3 from 2026-10-02.
Compile time and runtime, and the gap between them
Iron evaluates constraints at compile time or explicitly checks them at runtime, and the readme gives the two runtime entry points on the same value:
val runtimeValue: Double = ???
log(runtimeValue.refineUnsafe) //Explicitly refine your external values at runtime.
runtimeValue.refineEither.map(log) //Use monadic style for functional validation
runtimeValue.refineEither[Positive].map(log) //More explicitlyrefineUnsafe is the unsafe version: it produces the refined value directly and fails loudly if the constraint does not hold, so it is for values you already trust, such as a literal from a test or a value you have checked. refineEither returns an Either, which is the monadic form, and the second call names the constraint explicitly when the type does not already carry it.
The distinction is what makes the library usable at the boundary of a system. Values that come from a database, a JSON payload or a user form cannot be compile-time checked, because the compiler has never seen them. They have to be refined at runtime, and the choice between refineUnsafe and refineEither is the choice between a crash and a value you have to handle.
The error messages are where a lot of the library's value sits, and the readme shows both cases. A failed constraint produces a structured message with the type, the value and the constraint's own message:
-- Constraint Error --------------------------------------------------------
Could not satisfy a constraint for type scala.Double.
Value: -1.0
Message: Should be strictly positive
----------------------------------------------------------------------------The second case is more interesting, because it is the one that catches people. When a value cannot be verified at compile time, the error explains why and what to do instead:
-- Constraint Error --------------------------------------------------------
Cannot refine non full inlined input at compile-time.
To test a constraint at runtime, use the `refine` extension method.
Note: Due to a Scala limitation, already-refined types cannot be tested at compile-time (unless proven by an `Implication`).
Inlined input: runtimeValue
----------------------------------------------------------------------------The note about already-refined types is a genuine Scala limitation rather than an Iron one, and the mention of an `Implication` is the escape hatch: an implication is a proof that one constraint implies another, so a refined value can carry a further refinement that the compiler can discharge from the existing proof.
Writing your own constraint
The readme's fifth bullet says Iron is extendable, and that you can easily create your own constraints or integrations using classic typeclasses.
That is the claim that decides whether the library is a dead end or a foundation. If constraints could only be the ones that ship in the box, then adopting Iron means accepting its catalogue of built-in types, and the moment your domain needs a constraint that is not there you are stuck. If a constraint is an ordinary typeclass, then your domain constraint is a typeclass, and the machinery around compile-time evaluation, error reporting and runtime refinement works for it too.
The constraint namespace in the example import is io.github.iltotore.iron.constraint.numeric, which tells you the built-in constraints are organised by category, with numeric being one of them. The catalogue covers the kinds of invariants that appear in ordinary business code: positivity, non-negativity, ranges, and the like, expressed as named types that a domain vocabulary can reuse.
The topic list of the repository names refinement-types and types alongside functional-programming and assert, which matches the description of the project as type-level constraints rather than runtime assertions.
One design consequence of the typeclass approach is that constraints compose. If your own constraint can be expressed in terms of a built-in one, or in terms of another of your own, the composition is an ordinary instance rather than a special case in the library. That is the difference between a constraint system you can extend and one you can only consume.
A module per integration, and where the library stops
Iron ships as a family of modules rather than one artifact, and the platform support table is the map of what is covered.
The core iron module supports the JVM, JS and Native. Most of the integrations follow the same pattern, including iron-cats, iron-circe, iron-ciris, iron-chimney for data derivation, iron-decline, iron-jsoniter, iron-scodec, iron-skunk, iron-upickle, iron-zio and iron-zio-json, plus iron-play-json, iron-dynosaur, iron-borer for Borer serialization, iron-doobie and iron-pureconfig.
The exceptions are informative. iron-doobie and iron-pureconfig have no JS support, and iron-doobie, iron-dynosaur, iron-pureconfig, iron-scalacheck, iron-zio, iron-zio-blocks-schema, iron-zio-json and iron-play-json have no Native support. iron-borer is JVM and JS only.
That pattern is not arbitrary. Each of these libraries is either tied to a JVM-only library, such as doobie, which wraps a JDBC driver, or it uses a macro or reflection facility that Scala.js and Scala Native do not provide. If you are building for JS or Native, the constraint is on your choice of effect system and serialization library, not on Iron itself.
The repository layout mirrors the module list, with a directory per integration such as borer/, cats/, chimney/, circe/, ciris/, decline/, doobie/, dynosaur/, jsoniter/, playJson/, pureconfig/, scalacheck/, scodec/, skunk/, upickle/, zio/, zioBlocksSchema/ and zioJson/, plus main/ for the core and a sandbox/ directory. There is also an examples/ directory with borerSerialization, catsValidation, formCats, formJsoniter and formZio, which are the form-handling cases the readme alludes to when it says constraints can be checked at runtime, for example for a form.
The build is Mill rather than sbt, with build.mill at the root and millw and millw.bat wrappers, and formatting is handled by.scalafmt.conf.
Where Iron is the wrong choice
The first limitation is the one the error message itself admits. A compile-time check cannot apply to a value the compiler has never seen, so every value that enters your system from outside has to be refined explicitly, and the type of that refinement is your choice between a crash and an Either you have to handle. A codebase that reads most of its data from a database or a JSON API will find itself calling refineEither at every boundary, and the type safety you gained inside the core is only as good as the discipline at the edges.
The second is that the refinement does not propagate for free through every operation. Because an already-refined type cannot be re-tested at compile time unless an Implication proves it, you can hit a wall where a value is known positive but a function wants to prove it again. The workaround exists, but it means reasoning about implication proofs, which is a type-level skill of its own.
The third is the cost to the codebase. Refined types are subtypes, so they can be passed where plain values are expected, but the reverse is not true: a plain Double is not a positive Double. Any function that wants a refined value has to take a refined value in its signature, and that propagates through the call graph. On a large codebase this is an ongoing tax rather than a one-off change, and it is the reason libraries like this tend to be adopted in one module at a time rather than across a whole service.
The fourth is that Iron does not model state. A refined type says a value satisfies a predicate at the moment it was created. It says nothing about what happens next: a refined account balance does not know that it was just debited. That class of invariant belongs in a state machine or an effectful model, and Iron is not a substitute for it.
The fifth is the library's reach. The adopters list is a good sample of what it is used for: Clever Cloud and Ledger among the companies, Lichess among the projects, plus the gvolpe/trading project and a laminar form derivation library. That is a reasonable spread for a type-level library, but it is not a list of large Scala codebases that depend on it, and the integration modules for the less common libraries in the table are thinner than the core.
Editorial conclusion
Iron fits a Scala 3 team whose domain has invariants that the type system should carry, such as non-negative counts or validated identifiers, since a refined type is a subtype of its unrefined version and can be added or removed without rewriting call sites, and the error messages explain which constraint failed. It is the wrong choice if you need constraints checked on values that arrive at runtime, because a compile-time check cannot apply to them and the refineEither path has to be written explicitly, and it is the wrong choice for a large codebase that will need to thread refined types through every signature. Before adopting it, read the microsite and the constraint catalogue, check that the libraries you need have a matching integration module, and note that the last push was on 2026-10-19 with v3.4.1 released the same day under an Apache-2.0 licence.
Frequently asked questions
What is Iron in Scala 3?
Iron is a lightweight library for refined types in Scala 3 that attaches constraints or assertions to types so invalid values are forbidden. A refined type such as Double :| Positive is a subtype of the unrefined Double, so it can be used like a normal value inside the function that requires it.
How do I use Iron with values that come from outside the program?
Use the refine extension method at runtime. refineUnsafe produces the refined value directly and fails if the constraint does not hold, while refineEither returns an Either and supports the monadic style, and refineEither[Positive] names the constraint explicitly when the type does not already carry it.
How do I add Iron to a Scala project?
With sbt, add the dependency line libraryDependencies += "io.github.iltotore" %% "iron" % "version", and with Mill use mvn"io.github.iltotore::iron:version", replacing version with the version of Iron you want.
Can I define my own constraints with Iron?
Yes. The readme describes Iron as extendable, with custom constraints and integrations written using classic typeclasses, and it uses Scala 3's inline, types and restricted macros rather than a bespoke mechanism, so behaviour stays predictable.
Which platforms and libraries does Iron support?
The core iron module supports the JVM, JS and Native. Most integration modules follow suit, but iron-doobie and iron-pureconfig have no JS support, and several including iron-doobie, iron-pureconfig, iron-play-json and the zio modules have no Native support.
What licence is Iron released under?
Apache-2.0, with the licence file at the root of the repository. The last push was on 2026-10-19 and the newest release is v3.4.1 from the same day, following v3.4.0 on 2026-10-12.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/iltotore-iron)Was this useful?