Type refinements
Type refinements provide a mechanism for using types that you control inside the code generated by smithy4s. Creating a refinement for use in your application starts with creating a custom smithy trait that represents the refinement.
namespace test
@trait(selector: "string")
structure emailFormat {}
This trait can now be used on string
shapes to indicate that they must match an email format.
@emailFormat
string Email
Now we need to tell smithy4s that we want to represent shapes annotated with @emailFormat
as a custom type that we define.
Given a custom email type such as:
// Note, we recommend using a newtype library over a regular case class in most cases
// But this is shown to simplify the example
case class Email(value: String)
object Email {
private def isValidEmail(value: String): Boolean = ???
def apply(value: String): Either[String, Email] =
if (isValidEmail(value)) Right(new Email(value))
else Left("Email is not valid")
}
Next, we will need to provide a way for smithy4s to understand how to construct and deconstruct our Email
type. We do this by defining an instance of a RefinementProvider
. Note that the RefinementProvider
we create MUST be implicit
.
// package myapp.types
import smithy4s._
case class Email(value: String)
object Email {
private def isValidEmail(value: String): Boolean = ???
def apply(value: String): Either[String, Email] =
if (isValidEmail(value)) Right(new Email(value))
else Left("Email is not valid")
implicit val provider: RefinementProvider[EmailFormat, String, Email] = Refinement.drivenBy[EmailFormat](
Email.apply, // Tells smithy4s how to create an Email (or get an error message) given a string
(e: Email) => e.value // Tells smithy4s how to get a string from an Email
)
}
The EmailFormat
type passed as a type parameter to Refinement.drivenBy
is the type that smithy4s generated from our @emailFormat
trait we defined in our smithy file earlier.
Now, we just have one thing left to do: tell smithy4s where to find our custom Email
type. We do this using a trait called smithy4s.meta#refinement
.
use smithy4s.meta#refinement
apply test#emailFormat @refinement(
targetType: "myapp.types.Email"
)
Here we are applying the refinement trait to our emailFormat
trait we defined earlier. We are providing the targetType
which is our Email
case class we defined.
Smithy4s will now be able to update how it does code generation to reference our custom Email
type.
If the provider was not in the companion object of our targetType
, we would need to provide the providerImport
to the refinement
trait
so that smithy4s would be able to find it. For example:
use smithy4s.meta#refinement
apply test#emailFormat @refinement(
targetType: "myapp.types.Email",
providerImport: "myapp.types.providers._" // or `providerImport: "myapp.types.providers.given"` if you use scala 3
)
Whether the provider is in the companion object or not, it must be implicit
.
Parameterised Types
As of smithy4s version 0.17, you can now create refinements on types that take a generic type parameter. This can be accomplished by setting parameterised
to true
as seen below.
@trait(selector: "list")
@refinement(
targetType: "smithy4s.example.refined.NonEmptyList",
parameterised: true
)
structure nonEmptyListFormat {}
Following this, we now need to create our refinement provider as we did with the example above. In this case, we will use an implicit function so we can reference the generic type parameter A
. This allows us to use the same implementation of NonEmptyList
across lists containing any type.
import smithy4s._
case class NonEmptyList[A] private (values: List[A])
object NonEmptyList {
def apply[A](values: List[A]): Either[String, NonEmptyList[A]] =
if (values.size > 0) Right(new NonEmptyList(values))
else Left("List must not be empty.")
implicit def provider[A]: RefinementProvider[NonEmptyListFormat, List[A], NonEmptyList[A]] = Refinement.drivenBy[NonEmptyListFormat](
NonEmptyList.apply[A],
(b: NonEmptyList[A]) => b.values
)
}
Now we can apply our nonEmptyListFormat
trait as follows:
@nonEmptyListFormat
list NonEmptyStrings {
member: String
}
In the generated Scala code, this will render as a NonEmptyList[String]
instead of a List[String]
. Similarly, we can apply the nonEmptyListFormat
trait to any list
shape and it will render as a NonEmptyList
. This works for all shapes that can be specified as list members including primitives, structures, collections, and even other refined types.