Defining Newtypes
You can define newtypes with or without validation. Newtypes are a way to create type-safe wrappers around existing types, ensuring type safety and domain correctness at compile time.
With Validation
When you need to ensure that values conform to specific rules, use Newtype.ValidatedOf
. The underlying type is
inferred from the validator rules:
import yantl.*
case object Age extends Newtype.ValidatedOf(Validator.of(ValidatorRule.minValue(0L)))
type Age = Age.Type
This creates an Age
type that:
- Wraps a
Long
value - Ensures the value is non-negative
- Provides type safety (you can't accidentally use a raw
Long
where anAge
is expected)
Creating validated instances:
// Safe creation
Age.make(25)
// res0: Either[Vector[TError], Type] = Right(value = 25L)
Age.make(-5)
// res1: Either[Vector[TError], Type] = Left(
// value = Vector(SmallerThan(minimum = 0L, actual = -5L))
// )
// With errors as strings.
// You need to define how to convert the error messages to strings first.
given [A]: AsString[A] = AsString.fromToString
// Creation with multiple string error messages
Age.make.asStrings(-5)
// res2: Either[Vector[String], Type] = Left(
// value = Vector("Cannot be smaller than 0, was -5.")
// )
// Creation with a single string error message
Age.make.asString(-5)
// res3: Either[String, Type] = Left(
// value = "Cannot be smaller than 0, was -5."
// )
// Performs no checking, allowing invalid values to be created
Age.make.unsafe(-5)
// res4: Type = -5L
For statically known values, you can use makeOrThrow
to throw an exception if the value is invalid:
Age.make.orThrow(-5)
// java.lang.IllegalArgumentException: Cannot be smaller than 0, was -5.
// at yantl.Make.orThrow(Make.scala:47)
// at yantl.Make.orThrow$(Make.scala:15)
// at yantl.INewtype$make$.orThrow(Newtype.scala:31)
// at repl.MdocSession$MdocApp.$init$$$anonfun$1(001_defining_newtypes.md:49)
Without Validation
When you just need type safety without validation rules:
object Name extends Newtype.WithoutValidationOf[String]
type Name = Name.Type
This creates a Name
type that:
- Wraps a
String
value - Provides type safety
- Has no validation rules
Creating unvalidated instances:
Name("John")
// res5: Type = "John"
Intermediatary Newtypes
Sometimes (for example, in a framework) you want to define a newtype that has some functionality, but it is not the final type. For example, a newtype helper for non-empty strings:
/** A newtype wrapping a [[String]]. */
trait NewtypeString extends Newtype.Of[String] {
given CanEqual[Type, Type] = CanEqual.derived
given Ordering[Type] = Ordering.by(unwrap)
}
/** A newtype wrapping a non-empty [[String]] without surrounding whitespace. */
trait NewtypeNonEmptyString extends NewtypeString {
// Unfortunately you have to repeat the type here
type TError = ValidatorRule.HadSurroundingWhitespace | ValidatorRule.WasBlank
override val validate = NewtypeNonEmptyString.validator
}
object NewtypeNonEmptyString {
val validator = Validator.of(
ValidatorRule.nonBlankString,
ValidatorRule.withoutSurroundingWhitespace,
)
}
Then, end-user code can use NewtypeNonEmptyString
to get the Ordering
instance for free:
case object ForumTopic extends NewtypeNonEmptyString
type ForumTopic = ForumTopic.Type
ForumTopic.make("What are newtypes?")
// res6: Either[Vector[TError], Type] = Right(value = "What are newtypes?")
ForumTopic.make("")
// res7: Either[Vector[TError], Type] = Left(
// value = Vector(WasBlank(value = ""))
// )
Or they can refine the type even further:
case object ForumTopicStrict extends NewtypeString {
type TError =
ValidatorRule.HadSurroundingWhitespace |
ValidatorRule.WasBlank |
ValidatorRule.UnderMinLength[String]
override val validate =
NewtypeNonEmptyString.validator and Validator.of(ValidatorRule.minLength(10))
}
type ForumTopicStrict = ForumTopicStrict.Type
ForumTopicStrict.make("What are newtypes?")
// res8: Either[Vector[TError], Type] = Right(value = "What are newtypes?")
ForumTopicStrict.make("newtypes?")
// res9: Either[Vector[TError], Type] = Left(
// value = Vector(
// UnderMinLength(minLength = 10, actualLength = 9, actual = "newtypes?")
// )
// )
Chained Newtypes
You can have newtypes that are based off other newtypes. For example, you can define an Email
newtype and then more
specific GoogleMailEmail
newtype that refines Email
even more.
case class NotAnEmail(email: String)
case object Email extends Newtype.ValidatedOf(Validator.of(
ValidatorRule.of { (email: String) =>
if (email.contains("@")) None else Some(NotAnEmail(email))
}
))
type Email = Email.Type
case class NotAGoogleMail(email: Email)
case object GoogleMailEmail extends Newtype.ValidatedOf(Validator.of(
ValidatorRule.of { (email: Email) =>
if (Email.unwrap(email).endsWith("@gmail.com")) None else Some(NotAGoogleMail(email))
}
))
val ChainedGoogleMailEmail = Email.compose(GoogleMailEmail)
// ChainedGoogleMailEmail: INewtype {
type TUnderlying >: TUnderlying <: TUnderlying
type TError >: TError | TError <: TError | TError
type Type >: Type <: Type
} = ComposedNewtype(Email -> GoogleMailEmail)
type ChainedGoogleMailEmail = ChainedGoogleMailEmail.Type
val notAnEmail = ChainedGoogleMailEmail.make("foo")
// notAnEmail: Either[Vector[TError], Type] = Left(
// value = Vector(NotAnEmail(email = "foo"))
// )
val outlook = ChainedGoogleMailEmail.make("foo@outlook.com")
// outlook: Either[Vector[TError], Type] = Left(
// value = Vector(NotAGoogleMail(email = "foo@outlook.com"))
// )
val gmail = ChainedGoogleMailEmail.make("foo@gmail.com")
// gmail: Either[Vector[TError], Type] = Right(value = "foo@gmail.com")
gmail == Email.make("foo@gmail.com").flatMap(GoogleMailEmail.make(_))
// res10: Boolean = true
Best Practices
-
Always define both the object and type alias:
object MyType extends Newtype.WithoutValidationOf[String] type MyType = MyType.Type // Allows using `MyType` as your type in the codebase
-
Use validation when your type has invariants that must be maintained
-
Use
WithoutValidation
when you just need type safety -
Use
makeOrThrow
only when you are certain the value is valid, like in statically known values