A friendly newtype library for Scala 3.
"io.github.kitlangton" %% "neotype" % "x.y.z"
"io.github.kitlangton" %% "comptime" % "x.y.z" // optionalUse the latest version shown in the Sonatype badge above.
- Compile-time checked values using plain Scala expressions
- Helpful compilation errors (see below)
- Zero runtime overhead (thanks to
inlineandopaque type) - Runtime validation via
makeandmakeOrThrow - Integrations with popular libraries (e.g.
zio-json,circe,tapir) - Optional
comptimeengine for compile-time evaluation (see below)
Here is how to define a compile-time validated Newtype.
import neotype.*
// 1. Define a newtype.
object NonEmptyString extends Newtype[String]:
// 2. Optionally, define a validate method.
override inline def validate(input: String): Boolean =
input.nonEmpty
// 3. Construct values.
NonEmptyString("Hello") // OK
NonEmptyString("") // Compile ErrorAttempting to call NonEmptyString("") would result in the following compilation error:
Error: /src/main/scala/examples/Main.scala:9:16
NonEmptyString("")
^^^^^^^^^^^^^^^^^^
—— Neotype Error ——————————————————————————————————————————————————————————
NonEmptyString was called with an INVALID String.
input: ""
check: input.nonEmpty
———————————————————————————————————————————————————————————————————————————import neotype.*
// Type-safe IDs - prevent mixing up different entity types
type UserId = UserId.Type
object UserId extends Newtype[Long]
type OrderId = OrderId.Type
object OrderId extends Newtype[Long]
def getUser(id: UserId): User = ...
def getOrder(id: OrderId): Order = ...
getUser(UserId(123)) // ✓ Compiles
getUser(OrderId(456)) // ✗ Won't compile - type mismatch!
// Bounded numbers with validation
type Port = Port.Type
object Port extends Newtype[Int]:
override inline def validate(value: Int) =
if value >= 1 && value <= 65535 then true
else s"Port must be 1-65535, got: $value"
Port(8080) // ✓ Compiles
Port(99999) // ✗ Compile error: Port must be 1-65535
// Validated strings
type Username = Username.Type
object Username extends Newtype[String]:
override inline def validate(value: String) =
if value.length < 3 then "Username must be at least 3 characters"
else if !value.forall(_.isLetterOrDigit) then "Username must be alphanumeric"
else true
// Geographic coordinates
type Latitude = Latitude.Type
object Latitude extends Newtype[Double]:
override inline def validate(value: Double) =
if value >= -90 && value <= 90 then true
else s"Latitude must be -90 to 90, got: $value"See examples/src/main/scala/examples/NewtypeExamples.scala for more examples including validated strings, subtypes, runtime validation, and collections.
Neotype integrates with the following libraries:
- JSON
- DATABASE / STORAGE
- CONFIG
- ZIO
- zio-test
DeriveGen - zio-schema
- zio-test
- MISCELLANEOUS
import neotype.*
type NonEmptyString = NonEmptyString.Type
object NonEmptyString extends Newtype[String]:
override inline def validate(value: String): Boolean | String =
if value.nonEmpty then true else "String must not be empty"import neotype.interop.ziojson.given
import zio.json.*
case class Person(name: NonEmptyString, age: Int) derives JsonCodec
val parsed = """{"name": "Kit", "age": 30}""".fromJson[Person]
// Right(Person(NonEmptyString("Kit"), 30))
val failed = """{"name": "", "age": 30}""".fromJson[Person]
// Left(".name(String must not be empty)")By importing neotype.interop.ziojson.given, we automatically generate a JsonCodec for NonEmptyString. Custom
failure messages are also supported (by overriding def failureMessage in the Newtype definition).
Note that import neotype.interop.ziojson.given needs to be in the same file as Person, not NonEmptyString.
The generated JsonCodec is not made available to the entire project, but only to the file where it is imported.
Neotype ships an optional comptime module, a compile-time evaluator inspired by Zig's comptime.
It evaluates expressions at compile time and inlines the results as literals.
import comptime.*
val primes = comptime {
(2 to 50).toList.filter(n => (2 until n).forall(n % _ != 0))
}
// Compiles to: List(2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47)Parse and validate data at compile time. The inline def pattern lets you share logic
between compile-time and runtime versions:
import comptime.*
final case class SemVer(major: Int, minor: Int, patch: Int)
object SemVer:
// Shared parsing logic - inline so it works in both contexts
private inline def doParse(s: String): Either[String, SemVer] =
val parts = s.split("\\.").toList
parts match
case List(maj, min, pat) => Right(SemVer(maj.toInt, min.toInt, pat.toInt))
case _ => Left(s"Invalid semver: $s")
// COMPILE-TIME: invalid input = compile error
inline def parse(inline s: String): SemVer = comptime {
doParse(s).fold(comptimeError(_), identity)
}
// RUNTIME: for user input, returns Either
def parseEither(s: String): Either[String, SemVer] = doParse(s)
// Compile-time - literal in bytecode
val version = SemVer.parse("1.2.3") // → SemVer(1, 2, 3)
// Runtime - graceful error handling
SemVer.parseEither(userInput) match
case Right(v) => println(s"Valid: $v")
case Left(e) => println(s"Error: $e")Validate relationships between configuration constants at compile time.
Use inline val to define constants, then assert their invariants:
import comptime.*
inline def staticAssert(inline cond: Boolean, inline msg: String): Unit =
comptime { if !cond then comptimeError(msg) }
object Config:
inline val BUFFER_SIZE = 4096
inline val MAX_ITEMS = 100
inline val ITEM_SIZE = 40
// Catches bugs when ANY constant changes!
staticAssert(
BUFFER_SIZE >= MAX_ITEMS * ITEM_SIZE,
"Buffer too small for max items"
)
staticAssert(
(BUFFER_SIZE & (BUFFER_SIZE - 1)) == 0,
"Buffer size must be power of 2"
)Use comptimeError to produce descriptive compile errors:
inline def parsePort(inline s: String): Int = comptime {
val port = s.toInt
if port < 1 || port > 65535 then
comptimeError(s"Invalid port: $port. Must be 1-65535")
port
}
parsePort("8080") // ✓ Compiles to: 8080
parsePort("99999") // ✗ Compile error: Invalid port: 99999See examples/src/main/scala/examples/ComptimeExamples.scala for:
- Pre-computed lookup tables (primes, factorials)
- Duration literals (
"30s"→Duration.ofSeconds(30)) - Regex validation at compile time
- Protocol buffer sizing assertions
- Feature flag dependency checking
- Primitives: arithmetic, comparisons, boolean logic, bitwise ops
- Strings:
split,trim,toInt,substring,contains,matches, etc. - Collections:
List,Vector,Set,Mapwithmap,filter,fold, etc. - Control flow:
if/else, pattern matching,try/catch,valbindings - Case classes: construction, field access, pattern matching
- Options/Eithers/Try:
Some,None,Right,Left,Success,Failure, etc. - java.time:
Duration,LocalDate,LocalTimeconstruction and operations - Regex:
findFirstIn,findAllIn,replaceAllIn,matches, etc.
See SUPPORTED.md for the complete list.