Пост

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-типы и продемонстрировать некоторые возможности библиотеки.

Более подробно с примерами и списком предикатов можно ознакомиться в гитхабе проекта.

Авторский пост защищен лицензией CC BY 4.0 .

© Ainur. Некоторые права защищены.

Использует тему Chirpy для Jekyll