
В процессе написания программ мы часто сталкиваемся с данными, для которых возможен только ограниченный набор значений. Например, возраст, который не может быть отрицательным или email, который может иметь только определенный формат строки. При использовании примитивных типов (Int, String) приходится писать различные валидаторы, поскольку такие типы:
могут представлять все что угодно
могут содержать все что угодно
Тут еще можно упомянуть антипаттерн Primitive Obsession.
Если у нас есть некоторая функция, которая принимает в аргументы имя, email и возраст, описанные примитивными типами:
def foo(name: String, email: String, age: Int) = {
// что-то делаем с name, email и age
}Пользователь или мы сами можем случайно передать ей некорректные значения.
val result = foo("???", "", -20) // не окДобавление валидации в самом примитивном виде может выглядеть как то так.
def foo(name: String, email: String, age: Int) = {
if (!validateName(name) || !validateEmail(email) || !validateAge(age))
// Ошибка валидации
// Если валидация прошла успешно, то используем name, email и age
}Теперь функция foo делает не только то, для чего она действительно была создана, но еще и ответственна за то, чтобы в ней производилась валидация (тем самым нарушая первый принцип SOLID).
Может помогут псевдонимы типов (Type alias)?
Псевдонимы типов не помогут, поскольку они так же продолжают представлять примитивные типы.
type Name = String
type Email = String
type Age = Int
def foo(name: Name, email: Email, age: Age) = ???
val name: Name = "Mark"
val email: Email = "mark@email.com"
val age: Age = 42
foo(name, email, age) // ок
foo(email, name, age) // вообще не окИ компилятор не сможет сообщить, что мы допустили ошибку, случайно поменяв email и name местами.
А value class-ы?
Обернув примитивные типы в кейс-классы (тем самым создав спец. типы), мы не можем просто так взять и подсунуть Name вместо Email.
final case class Name(value: String) extends AnyVal
final case class Email(value: String) extends AnyVal
final case class Age(value: Int) extends AnyVal
def foo(name: Name, email: Email, age: Age) = ???
foo(Name("Mark"), Email("mark@email.com"), Age(42)) // ок
foo(Email("mark@email.com"), Name("Mark"), Age(42)) // ошибка компиляции
// type mismatch;
// found : org.github.ainr.experiments.Main.Email
// required: org.github.ainr.experiments.Main.NameНо эти типы все еще могут принимать любые значения.
val email = Email("blah blah blah") // не окИспользуем refined (уточненные) типы
Refined - это небольшая библиотека для Scala, позволяющая описать тип с помощью предикатов, ограничивающих набор значений, который может принимать этот тип.
Для использования refined-типов необходимо подключить библиотеку refined добавив следующую строчку в build.sbt
libraryDependencies += "eu.timepit" %% "refined" % "0.9.27"Предварительно проверив не появилась ли версия поновее.
Начнем с чего-нибудь простого. Например, возраст не может быть отрицательным числом. С помощью предиката NonNegative уточняем тип Int создав при этом тип Age.
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.numeric._
type Age = Int Refined NonNegativeПри попытке присвоить значение несоответствующее предикату код даже не скомпилируется.
val age: Age = -1
// Predicate (-1 < 0) did not fail.
// val positiveInteger: Age = -1Так же предикаты могут быть скомбинированы. К примеру, тут создается тип, который может принимать только положительные нечетные числа.
type OddPositive = Int Refined (Odd And Positive)
val anOddPositive: OddPositive = 3Refined очень полезен для уточнения строковых типов с помощью готовых предикатов.
val nonEmptyString: NonEmptyString = ""
// Predicate isEmpty() did not fail.
// val nonEmptyString: NonEmptyString = ""
type URL = String Refined Url
val url: URL = "http://github.com"
type EndsWithDot = String Refined EndsWith["."]
val endsWithDot: EndsWithDot = "Hello world."Так и предикатов на основе самодельных регулярных выражений.
type Name = String Refined MatchesRegex["""[A-Z][a-z]+"""]
type Email = String Refined MatchesRegex["""(\w)+@([\w\.]+)"""]Взаимодействие с библиотеками
Давайте рассмотрим пример взаимодействия с другими библиотеками, например circe, который используется для работы с json. Допустим, что у нас есть некоторый сервис с методом /api/foo через который с фронта прилетает json. Нам нужно преобразовать этот json в кейс-класс Foo поля которого имеют refined-типы.
import eu.timepit.refined.api.Refined
import eu.timepit.refined.string._
import eu.timepit.refined.numeric._
type Name = String Refined MatchesRegex["""[A-Z][a-z]+"""]
type Email = String Refined MatchesRegex["""(\w)+@([\w\.]+)"""]
type Age = Int Refined NonNegative
case class Foo(name: Name, email: Email, age: Age)Для того чтобы circe мог работать с refined-типами ему требуются инстансы с кодеками для refined-типов. Они определены в отдельном расширении circe-refined для подключения которого нужно добавить следующую строчку в build.sbt.
libraryDependencies += "io.circe" %% "circe-refined" % "0.14.1"И сделать импорт там, где будет выполняться json-преобразование.
import io.circe.generic.auto._, io.circe.parser._
import io.circe.refined._
val json =
"""
|{
| "name": "Martin",
| "email": "martin?email.com", // ошибка
| "age": 55
|}
|""".stripMargin
val decodedFoo: Either[circe.Error, Foo] = decode[Foo](json)
// Left(DecodingFailure(Predicate failed: "martin?email.com".matches("(\w)+@([\w\.]+)")., List(DownField(email))))Собственно, если json будет содержать некорректные значения, то мы в результате получим Either с ошибкой декодирования (DecodingFailure).
Неполный список доступных расширений для интеграции различных библиотек с refined-типами приведен в документации.
И кстати, преобразование примитивных типов в refined-типы в рантайме происходит примерно следующим способом.
import eu.timepit.refined.api.RefType
import eu.timepit.refined.api.Refined
import eu.timepit.refined.string._
type Email = String Refined MatchesRegex["""(\w)+@([\w\.]+)"""]
val badEmail = "bad email"
val email = RefType.applyRef[Email](badEmail)
// email = Left(Predicate failed: "bad email".matches("(\w)+@([\w\.]+)").)Вместо заключения
Целью этого небольшого поста ставил обозначить проблему, для решения которой были созданы refined-типы и продемонстрировать некоторые возможности библиотеки.
Более подробно с примерами и списком предикатов можно ознакомиться в гитхабе проекта.
