Refined типы в Scala
В процессе написания программ мы часто сталкиваемся с данными, для которых допустим только ограниченный набор значений. Например, возраст, который не может быть отрицательным или email, который может иметь только определенный формат строки. При использовании примитивных типов (Int
, String
) приходится писать различные валидаторы, поскольку такие типы:
- могут представлять все что угодно
- могут содержать все что угодно
Тут еще можно упомянуть антипаттерн Primitive Obsession.
Если у нас есть некоторая функция, которая принимает в аргументы имя, email и возраст, описанные примитивными типами:
1
2
3
def foo(name: String, email: String, age: Int) = {
// что-то делаем с name, email и age
}
Пользователь или мы сами можем случайно передать ей некорректные значения.
1
val result = foo("???", "", -20) // не ок
Добавление валидации в самом примитивном виде может выглядеть как то так.
1
2
3
4
5
6
def foo(name: String, email: String, age: Int) = {
if (!validateName(name) || !validateEmail(email) || !validateAge(age))
// Ошибка валидации
// Если валидация прошла успешно, то используем name, email и age
}
Теперь функция foo
делает не только то, для чего она действительно была создана, но еще и ответственна за то, чтобы в ней производилась валидация (тем самым нарушая первый принцип SOLID).
Может помогут псевдонимы типов (Type alias
)?
Псевдонимы типов не помогут, поскольку они так же продолжают представлять примитивные типы.
1
2
3
4
5
6
7
8
9
10
11
12
13
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
.
1
2
3
4
5
6
7
8
9
10
11
12
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
Но эти типы все еще могут принимать любые значения.
1
val email = Email("blah blah blah") // не ок
Используем refined (уточненные) типы
Refined - это небольшая библиотека для Scala, позволяющая описать тип с помощью предикатов, ограничивающих набор значений, который может принимать этот тип.
Для использования refined-типов необходимо подключить библиотеку refined добавив следующую строчку в build.sbt
1
libraryDependencies += "eu.timepit" %% "refined" % "0.9.27"
Предварительно проверив не появилась ли версия поновее.
Начнем с чего-нибудь простого. Например, возраст не может быть отрицательным числом. С помощью предиката NonNegative
уточняем тип Int
создав при этом тип Age
.
1
2
3
4
5
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.numeric._
type Age = Int Refined NonNegative
При попытке присвоить значение несоответствующее предикату код даже не скомпилируется.
1
2
3
val age: Age = -1
// Predicate (-1 < 0) did not fail.
// val positiveInteger: Age = -1
Так же предикаты могут быть скомбинированы. К примеру, тут создается тип, который может принимать только положительные нечетные числа.
1
2
3
type OddPositive = Int Refined (Odd And Positive)
val anOddPositive: OddPositive = 3
Refined очень полезен для уточнения строковых типов с помощью как готовых предикатов.
1
2
3
4
5
6
7
8
9
val nonEmptyString: NonEmptyString = ""
// Predicate isEmpty() did not fail.
// val nonEmptyString: NonEmptyString = ""
type URL = String Refined Url
val url: URL = "https://github.com"
type EndsWithDot = String Refined EndsWith["."]
val endsWithDot: EndsWithDot = "Hello world."
Так и предикатов на основе самодельных регулярных выражений.
1
2
type Name = String Refined MatchesRegex["""[A-Z][a-z]+"""]
type Email = String Refined MatchesRegex["""(\w)+@([\w\.]+)"""]
Взаимодействие с библиотеками
Давайте рассмотрим пример взаимодействия с другими библиотеками, например circe, который используется для работы с json. Допустим, что у нас есть некоторый сервис с методом /api/foo
через который с фронта прилетает json. Нам нужно преобразовать этот json в кейс-класс Foo
поля которого имеют refined-типы.
1
2
3
4
5
6
7
8
9
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
.
1
libraryDependencies += "io.circe" %% "circe-refined" % "0.14.1"
И сделать импорт там, где будет выполняться json-преобразование.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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-типы в рантайме происходит примерно следующим способом.
1
2
3
4
5
6
7
8
9
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)
Вместо заключения
Целью этого небольшого поста ставил обозначить проблему, для решения которой были созданы refined-типы и продемонстрировать некоторые возможности библиотеки.
Более подробно с примерами и списком предикатов можно ознакомиться в гитхабе проекта.