В процессе написания программ мы часто сталкиваемся с данными, для которых возможен только ограниченный набор значений. Например, возраст, который не может быть отрицательным или 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 = 3
Refined очень полезен для уточнения строковых типов с помощью готовых предикатов.
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-типы и продемонстрировать некоторые возможности библиотеки.
Более подробно с примерами и списком предикатов можно ознакомиться в гитхабе проекта.