Skip to main content

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
)
}
info

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.

info

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.