neotype

repository·main·Indexed 42 days ago

https://github.com/kitlangton/neotype

A Scala 3 library for creating type-safe newtypes and subtypes with zero runtime overhead. It features compile-time validation of values using plain Scala expressions and an optional `comptime` engine for static assertions and evaluation of primitives, collections, regex, and java.time operations.

Tokens
5K
Snippets
11
Records
26
Agent score
53%

What's inside neotype

  1. Overview of supported language constructs in `comptime(...)`

    main

    The comptime(...) block allows for compile-time evaluation of Scala-like expressions. Supported constructs include:

    • Literals: Int, Long, Float, Double, Boolean, Char, Byte, Short, String, Unit, tuples, and collections.
    • Union Types: Supports A | B syntax (e.g., String | Boolean).
    • Control Flow: if/else expressions, try/catch/finally expressions (including exception type matching and variable binding), and throw expressions (only for existing Throwable instances, not constructor calls).
    • Variables: Blocks with local vals and vars (mutable local variables with assignment).
    • Lambdas: Supported when used within standard library operations like map, filter, or foldLeft.
    • Member Access: Basic field access is supported when the receiver is a constant (e.g., case class fields).
  2. Pattern matching in `comptime(...)`

    main

    Pattern matching is fully supported within comptime blocks. You can use:

    • Patterns: Literal, wildcard, variable, and typed patterns (case _: T, case x: T).
    • Guards: if conditions within cases.
    • Alternatives: case a | b syntax.
    • Tuple Patterns: Support for Tuple2 through Tuple5.
    • Standard Extractors:
      • Option, Either, Try (Some, None, Left, Right, Success, Failure).
      • Collections: List, Vector, Seq, Array, Nil, :: (cons), and _ * (varargs).
      • Regex extractors via "..." .r.
    • Custom Extractors: unapply and unapplySeq are supported.
    • Binders: @ binders for alternatives and nested patterns.
  3. How the `comptime` module works

    main

    The comptime module is an optional engine that evaluates Scala expressions at compile time and inlines the results as literals. This allows for pre-computing values, performing compile-time parsing, and running static assertions.

    Compile-time Parsing

    You can share logic between compile-time and runtime by using the inline def pattern. Use comptime inside an inline method to force evaluation during compilation.

    Static Assertions

    Use staticAssert to validate relationships between constants at compile time. If the condition is false, the compilation will fail with the provided message.

    Custom Compile Errors

    Use comptimeError(message) within a comptime block to produce descriptive, custom error messages during compilation.

    import comptime.*
    
    // Compile-time evaluation
    val primes = comptime {
      (2 to 50).toList.filter(n => (2 until n).forall(n % _ != 0))
    }
    
    // Static assertion
    inline def staticAssert(inline cond: Boolean, inline msg: String): Unit =
      comptime { if !cond then comptimeError(msg) }
    
    staticAssert(4096 >= 100 * 40, "Buffer too small")
  4. Measure compile-time performance impact

    main

    To measure the performance impact of changes to the rule engine or macro-heavy compile steps, use shell timing with sbt. It is recommended to run each command multiple times and compare the median of 3 runs to account for JVM warm-up.

    Baseline Measurement

    Run the following commands on your baseline branch:

    (time sbt "clean; comptimeJVM/compile")
    (time sbt "clean; neotypeJVM/test")

    Comparison

    Run the same commands on the branch containing the performance improvements and compare the results.

  5. Define and use a Newtype

    main

    A Newtype allows you to create type-safe wrappers around existing types with zero runtime overhead using Scala 3 opaque type and inline.

    To define one, extend Newtype[T] and optionally override the validate method. The validate method can return a Boolean for simple checks or a Boolean | String (or Either[String, Boolean]) to provide custom error messages.

    Compile-time validation

    If you pass a literal that fails the validate check, the compiler will throw a descriptive error.

    Type safety

    Newtypes prevent accidental mixing of different types that share the same underlying representation (e.g., preventing a UserId from being used where an OrderId is expected).

    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 Error
  6. Install neotype and comptime

    main

    Add the following dependencies to your Scala project. Use the latest version available on Maven Central.

    To use the core newtype functionality:

    "io.github.kitlangton" %% "neotype" % "x.y.z"

    To use the optional comptime engine for compile-time evaluation:

    "io.github.kitlangton" %% "comptime" % "x.y.z"
    "io.github.kitlangton" %% "neotype" % "x.y.z"
    "io.github.kitlangton" %% "comptime" % "x.y.z"
  7. Define a Newtype for compile-time validation

    main

    Use Newtype[A] to create a distinct type from an existing type A. By overriding the validate method, you can enforce constraints at compile-time. If the input is a literal or constant expression, apply will perform validation during compilation and raise an error if it fails. For runtime values, use make or makeOrThrow.

    // DEFINITION
    type Digits = Digits.Type
    object Digits extends Newtype[String]:
      override inline def validate(value: String) = 
        value.forall(_.isDigit)
    
    // COMPILE-TIME VALIDATION
    Digits("123") // Compiles successfully.
    Digits("abc") // Fails to compile due to validation.
    
    // RUN-TIME VALIDATION
    val input = "123"
    Digits.make(input) // Right(Digits("123"))
    
    val bad = "abc"
    Digits.make(bad) // Left("String must be numeric")
  8. Known limitations and gaps in `comptime(...)`

    main

    The following features are not guaranteed to work in comptime blocks unless explicitly covered by the test suite:

    • Loops: while loops are not supported.
    • Lazy Values: lazy val is not supported.
    • Exception Constructors: new RuntimeException("msg") is not supported; use existing Throwable instances instead.
    • Arbitrary Methods: Any method call outside the whitelisted standard library rules.
    • Reflection/IO: General reflection or IO (except for comptime.readFile and comptime.readFileBytes).
    • Variable Limitations:
      • Functions in vars: You cannot store a lambda in a var and then call it (e.g., var f = (x: Int) => x + 1; f(1) will fail).
      • Unused val exceptions: If a var is in scope, an unused val whose initializer would throw an exception might not actually throw.
  9. Use Newtype with ZIO JSON

    main

    Neotype provides interop modules for popular libraries. To use neotype with zio-json, import the specific interop given in the file where your case class is defined.

    Note: The import neotype.interop.ziojson.given must be in the same file as the case class using the newtype, not in the file where the newtype is defined. The generated codec is only available in the file where it is imported.

    import neotype.*
    import neotype.interop.ziojson.given
    import zio.json.*
    
    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"
    
    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)")
  10. Use `comptime` helpers to read files

    main

    You can read files at compile time using the comptime helper module. Paths resolve relative to the call site.

    Note: File changes do not automatically trigger recompilation; these APIs should be treated like embedded constants.

    // Reads file as String
    val content = comptime.readFile("config.txt", "UTF-8")
    
    // Reads file as bytes
    val bytes = comptime.readFileBytes("data.bin")
  11. Collection operations in `comptime(...)`

    main

    A wide range of collection types and operations are available.

    Supported Types:

    • List, Vector, Set, Map, Array, LazyList.

    Key Operations:

    • Factories: List.fill, Vector.tabulate, etc.
    • Sequence Ops: take, drop, slice, ++, :+, +:, reverse, distinct, indexOf, indexWhere.
    • Predicates: exists, forall, find, count, takeWhile, dropWhile, partition.
    • Transformations: map, flatMap, filter, collect, groupBy, groupMap, scanLeft, scanRight.
    • Zipping/Folding: zip, unzip, foldLeft, foldRight, reduce, reduceOption.
    • Map Ops: get, apply, getOrElse, updated, removed, keys, values.
    • Iterators: hasNext, next, grouped, sliding, Iterator.from(start, step).
    • For-comprehensions: Supported for collections and Option, including guards (e.g., for (x <- xs if pred) yield f(x)).