Iron Refined Types for Scala 3
repository·main·Indexed 19 days ago
https://github.com/iltotore/ironA lightweight Scala 3 library for refined types that allows developers to attach constraints to types to enforce properties at both compile-time and runtime. It leverages Scala 3's inline types and macros to provide static typing and runtime refinement via methods like .refineEither and .refineUnsafe. Iron includes integrations for borer serialization, Cats validation, Decline CLI parsing, and supports various platform targets (JVM, JS, Native).
What's inside Iron
- Iron provides several support and interoperability modules designed to integrate refinement types seamlessly with other Scala ecosystems. These modules primarily provide typeclass instances or specialized methods (like accumulative refinement) to allow Iron's refinements to work with common libraries for JSON serialization, configuration, database access, and more.
Overview of Iron for Refined Types
mainIron is a lightweight library for Scala 3 that enables attaching constraints or assertions to types. This allows you to enforce specific properties and forbid invalid values at both compile-time and runtime.
Key features:
- Catch bugs: Use specific types to avoid invalid values via static typing.
- Compile-time and runtime: Evaluate constraints during compilation or explicitly check them at runtime (e.g., for user input).
- Seamless integration: Iron types are subtypes of their unrefined versions, allowing for easy conversion.
- Extendable: Create custom constraints using standard typeclasses.
- No black magic: Built using Scala 3's
inline, types, and restricted macros for predictable behavior.
What is Implication in Iron
mainImplication is a compile-time mechanism that allows casting from one refined type to another. It is analogous to logical implication and is represented by
Implication[C1, C2]or its type aliasC1 ==> C2(read as "C1 implies C2").This mechanism allows you to treat a value with a stricter constraint as a value with a weaker constraint. For example, if a value is known to be
Greater[5], it can be safely cast toGreater[0]becauseGreater[5]impliesGreater[0]via transitivity.import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.numeric.Greater val x: Int :| Greater[5] = ??? val y: Int :| Greater[0] = x // Compiles because Greater[5] ==> Greater[0]Compose constraints using Union and Intersection
mainYou can compose existing constraints using type-level operators that act as boolean logic.
- Union (
|): Acts as a boolean OR. A value is valid if it satisfies either constraint. - Intersection (
&): Acts as a boolean AND. A value must satisfy both constraints.
Example: Creating a
GreaterEqual[V]constraint by unioningGreater[V]andStrictEqual[V].import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.all.* type GreaterEqual[V] = Greater[V] | StrictEqual[V] type Between[Min, Max] = GreaterEqual[Min] & LessEqual[Max]- Union (
How Scodec integration works
mainThe
iron-scodecmodule implements integration via two primary mechanisms:- Newtypes: It utilizes
RefinedType.Mirrorto provide codecs for newtype definitions. - Refined Types: It leverages the codec of the underlying base type and applies the refinement constraint validation during the decoding phase.
This integration is designed to work seamlessly with Scodec's native derivation mechanisms for case classes and sealed traits.
- Newtypes: It utilizes
Understand Iron core data types
mainIron is built around three fundamental concepts used to define and enforce type safety:
- Iron Type: A type that is constrained by one or more rules.
- Constraint: A specific rule or condition that a value must satisfy to be considered a valid Iron Type.
- Implication: A logical relationship where one constraint implies another (e.g., if a value satisfies constraint A, it must also satisfy constraint B).
What are refined types in Iron
mainIron is a type constraints (or "refined types") library for Scala. It allows you to bind specific constraints to a type, a process called "type refinement".
While standard Scala static typing prevents passing the wrong type (e.g., passing a
Stringwhere anIntis expected), it cannot prevent logically invalid values (e.g., passing a negative number for anage). Refined types solve this by ensuring constraints are checked at compile time or explicitly at runtime, preventing invalid data from propagating through your application.Understand the Iron Type representation
mainIn Iron, refined types are represented using the
IronType[A, C]structure, whereAis the base type andCis the constraint (or refinement) attached to it.To improve readability, Iron provides a concise alias
A :| C, which is inspired by the mathematical predicate operator. For example,Int :| Greater[0]is equivalent toIronType[Int, Greater[0]]and represents anIntthat must be greater than zero.Key properties of refined types:
- Subtyping: A refined type is a subtype of its unrefined base type. For example,
Int :| Greater[0]is a subtype ofInt. - Zero Overhead:
IronType[A, C]is an opaque type alias ofA. At runtime, a refined type desugars to its raw base type, meaning there is no performance penalty for using them.
import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.numeric.Greater // Using the concise alias syntax val x: Int :| Greater[0] = ??? // Demonstrating subtyping: a refined Int can be assigned to a raw Int val y: Int = x // Compiles- Subtyping: A refined type is a subtype of its unrefined base type. For example,
Automatic compile-time refinement
mainIf a value and its constraint are both fully inlined and evaluable at compile time, Iron automatically casts the unconstrained value to its refined form. If the constraint is not satisfied, a compile-time error is thrown. Note that if the value or constraint cannot be evaluated at compile time (e.g., a runtime variable), compilation will fail with a
Constraint Errorstating that you must use runtime refinement methods instead.import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.numeric.* // OK: 5 satisfies Greater[0] at compile time val x: Int :| Greater[0] = 5 // Compile Error: -1 does not satisfy Greater[0] // val y: Int :| Greater[0] = -1 // Compile Error: runtimeValue is not evaluable at compile time // val runtimeValue: Int = ??? // val x: Int :| Greater[0] = runtimeValueUnderstand opacity and type safety in Iron types
mainIron new types are opaque. This means they are only known as their specific type within the file where they are defined. Outside that file, they are treated as their underlying
IronType(the base type plus the constraint), not as the new type itself.Benefits
- Prevents accidental mixing: You cannot pass a
Moisturetype into a function expecting aTemperaturetype, even if both areDouble :| Positive. - Forces explicit conversion: You cannot assign a raw
Double :| Positiveto aTemperaturetype directly; you must use a smart constructor likeTemperature(value).
Limitations
Because of opacity, you cannot treat the new type as a simple type alias in other modules. You must interact with it via its defined interface.
- Prevents accidental mixing: You cannot pass a
Use Arbitrary instances for Iron types in ScalaCheck
mainOnce the
iron-scalacheckdependency is added, all Iron refined types are compatible with ScalaCheck viaArbitraryinstances.- Fallback instances: Every refined type has a default
Arbitraryinstance provided by the module. - Custom instances: Certain constraints include optimized custom
Arbitraryinstances. These are designed to be faster and more efficient at generating valid values, helping to prevent test exhaustion (where the generator struggles to find values that satisfy the refinement).
- Fallback instances: Every refined type has a default
Use RuntimeConstraint for performance and non-inline methods
mainWhile
Constraintis used for compile-time refinement,RuntimeConstraintis a proxy designed for runtime usage.When to use
RuntimeConstraint:- When the calling method does not need to be
inline. - To reduce generated bytecode size and improve performance.
- When deriving typeclasses (e.g.,
FromString) to avoid warnings about large generated code frominline givenaliases. - When you need to reuse the same constraint instance at runtime.
RuntimeConstraintprovides the sametestandmessageinterface asConstraint.// Example of using RuntimeConstraint in a refinement function def refineOption[A, C](value: A)(using constraint: RuntimeConstraint[A, C]): Option[A :| C] = Option.when(constraint.test(value))(value.asInstanceOf[A :| C]) refineOption[Int, Positive](5) // Some(5) refineOption[Int, Positive](-5) // None- When the calling method does not need to be