Привет, Хабр! Классическая ошибка: функция принимает userId: Int и orderId: Int. Вы случайно передаёте orderId вместо userId. Компилятор молчит — оба Int. В рантайме — запрос к базе по чужому ID, неправильные данные, баг, который выловят через неделю в проде.

Решение кажется очевидным — обернуть Int в отдельный тип. case class UserId(value: Int). Типобезопасно, но за это приходится платить: каждый UserId — объект на куче, аллокация, boxing, GC‑давление.

В Scala 2 пытались решить через AnyVal (value classes), но они боксились в куче случаев: дженерики, коллекции, паттерн‑матчинг.

Scala 3 предложила более интересное решение: opaque types. Типобезопасность на уровне компилятора, ноль overhead в рантайме. Снаружи отдельный тип, а внутри голый Int. Разберём, как это работает.

Идея: тип, которого нет в рантайме

object Ids:
  opaque type UserId = Int
  opaque type OrderId = Int

  object UserId:
    def apply(id: Int): UserId = id

  object OrderId:
    def apply(id: Int): OrderId = id

  extension (id: UserId)
    def value: Int = id

  extension (id: OrderId)
    def value: Int = id

Внутри объекта Ids компилятор знает, что UserId — это Int. Можно присваивать, вычислять, возвращать. Снаружи — UserId и Int абсолютно разные типы:

import Ids.*

val user = UserId(42)
val order = OrderId(42)

def findUser(id: UserId): User = ???
def findOrder(id: OrderId): Order = ???

findUser(user)     // OK
findUser(order)    // Ошибка компиляции! OrderId ≠ UserId
findUser(42)       // Ошибка компиляции! Int ≠ UserId

В байткоде UserId не существует. Никаких обёрточных классов, никаких аллокаций и JVM видит обычный int.

Альтернативы

  1. Type alias (type UserId = Int): никакой безопасности. UserId и Int взаимозаменяемы везде. Компилятор не поймает ошибку.

  2. Case class (case class UserId(value: Int)): полная безопасность, но runtime‑объект. Boxing в дженериках, аллокации, GC.

  3. AnyVal (class UserId(val value: Int) extends AnyVal): попытка убрать overhead, но боксится в коллекциях (List[UserId]), дженериках, при паттерн‑матчинге, при присвоении в Any. На практике у нас AnyVal боксится чаще, чем хотелось бы.

  4. Opaque type: безопасность как у case class, overhead как у type alias (ноль). Не боксится в дженериках, потому что в рантайме это уже голый Int.

Валидация при создании

Opaque type по умолчанию принимает любое значение базового типа. Но часто нужна валидация, например, Email должен содержать @:

object Types:
  opaque type Email = String

  object Email:
    def apply(raw: String): Either[String, Email] =
      if raw.contains("@") && raw.contains(".") then Right(raw)
      else Left(s"Invalid email: $raw")

    // Unsafe-вариант для случаев, когда вы уверены
    def unsafeFrom(raw: String): Email = raw

  extension (e: Email)
    def value: String = e
    def domain: String = e.split("@").last
import Types.*

val email = Email("alice@example.com")  // Right(ivan@example.com)
val bad = Email("not-an-email")         // Left(Invalid email: not-an-email)

email.map(_.domain)  // Right("example.com")

Подобный паттерн даёт безопасное создание. unsafeFrom — для случаев, когда данные уже провалидированы (из базы, из доверенного источника).

Можно пойти дальше и сделать валидацию в compile‑time через inline:

object Types:
  opaque type PositiveInt = Int

  object PositiveInt:
    inline def apply(n: Int): PositiveInt =
      inline if n <= 0 then
        compiletime.error("PositiveInt must be > 0")
      else n

    def from(n: Int): Option[PositiveInt] =
      if n > 0 then Some(n) else None

PositiveInt(0) — ошибка компиляции. PositiveInt(-5) — ошибка компиляции.

Opaque types с bounds

Opaque type может иметь верхнюю границу, с этим можно использовать методы базового типа снаружи:

object Measures:
  opaque type Meters <: Double = Double
  opaque type Kilograms <: Double = Double

  object Meters:
    def apply(d: Double): Meters = d

  object Kilograms:
    def apply(d: Double): Kilograms = d

Meters <: Double означает: снаружи известно, что Meters — подтип Double. Можно использовать методы Double (+, *, toString), но нельзя передать Meters туда, где ждут Kilograms:

import Measures.*

val distance = Meters(100.0)
val weight = Kilograms(75.0)

val doubled = Meters(distance * 2)  // OK — * от Double доступен
// val wrong: Kilograms = distance   // Ошибка! Meters ≠ Kilograms

Bounds конечно представляет из себя компромисс. Вы получаете удобство (не нужно писать extension для каждой операции), но теряете часть изоляции (все методы Double становятся доступны).

Extension methods: добавляем поведение

Opaque types не наследуют методы базового типа (если нет bounds). Всё поведение через extension:

object Money:
  opaque type USD = BigDecimal

  object USD:
    def apply(amount: BigDecimal): USD = amount
    def apply(amount: Double): USD = BigDecimal(amount)
    val zero: USD = BigDecimal(0)

  extension (a: USD)
    def +(b: USD): USD = a + b           // Внутри scope — USD это BigDecimal
    def -(b: USD): USD = a - b
    def *(factor: Double): USD = a * BigDecimal(factor)
    def amount: BigDecimal = a
    def formatted: String = f"$$$a%.2f"
    def isPositive: Boolean = a > BigDecimal(0)
import Money.*

val price = USD(29.99)
val tax = USD(2.40)
val total = price + tax        // USD(32.39)

println(total.formatted)       // $32.39
println(total.isPositive)      // true

// val wrong = total + 10.0    // Ошибка! Double ≠ USD
// val wrong = total + EUR(5)  // Ошибка! EUR ≠ USD (если определить EUR)

Арифметика USD + USD — ок. USD + Double — ошибка компиляции. Нельзя случайно сложить доллары с просто числом или с другой валютой.

Opaque types + given/using: type classes

Opaque types отлично сочетаются с type classes:

import Money.USD
import io.circe.{Encoder, Decoder}

// JSON-кодирование для USD
given Encoder[USD] = Encoder[BigDecimal].contramap(_.amount)
given Decoder[USD] = Decoder[BigDecimal].map(USD(_))

// Ordering
given Ordering[USD] = Ordering[BigDecimal].on(_.amount)

// Показ
given Show[USD] with
  def show(usd: USD): String = usd.formatted

Для внешнего мира USD является полноценным тиом с JSON‑сериализацией, сортировкой, отображением. Для JVM — BigDecimal без обёртки.

Паттерн: модуль доменных типов

В проекте удобно собрать все opaque types в один модуль:

object DomainTypes:
  // Идентификаторы
  opaque type UserId = Long
  opaque type OrderId = Long
  opaque type ProductId = Long

  // Строковые типы
  opaque type Email = String
  opaque type PhoneNumber = String

  // Числовые
  opaque type Quantity = Int
  opaque type Price = BigDecimal

  // Фабрики
  object UserId:
    def apply(id: Long): UserId = id
  object OrderId:
    def apply(id: Long): OrderId = id
  // ...

  // Extensions
  extension (id: UserId)
    def toLong: Long = id
  extension (id: OrderId)
    def toLong: Long = id
  // ...

Теперь сигнатура функции говорит сама за себя:

def createOrder(
    user: UserId,
    product: ProductId,
    quantity: Quantity,
    price: Price
): OrderId

Попробуйте перепутать аргументы — компилятор не даст. А в рантайме — четыре примитива, ноль объектов.

Ограничения

  • Opaque types существуют только в Scala. В Java‑коде opaque type видится как его базовый тип. Если у вас такой вот микс Scala/Java проект — из Java UserId и Long неразличимы.

  • Нельзя делать pattern matching по opaque type снаружи scope определения. Внутри scope — можно (потому что там компилятор знает базовый тип).

  • Нет автоматического equals/hashCode. Opaque type наследует equals/hashCode базового типа, что обычно нормально (два UserId(42) равны), но если нужна кастомная логика — extension methods.

  • Дженерики и boxing. Opaque type на основе примитива (Int, Long, Double) в дженерик‑контексте (List[UserId]) будет боксирован — потому что это просто List[Int], а дженерики в JVM работают через boxing. Это не проблема opaque types — это проблема JVM. Точно так же боксится обычный Int в List[Int]. Opaque type ничего не добавляет и не убирает.

Opaque types — одна из тех самых годных фич Scala. Используйте.

Если вам важно не просто знать отдельные возможности Scala, а использовать их в реальной разработке — так, чтобы точнее моделировать домен, раньше ловить ошибки и писать более предсказуемый код, — курс «Scala‑разработчик» стоит рассматривать именно как практический следующий шаг.

30–31 марта на любые курсы OTUS действует дополнительная скидка 10% по промокоду birthday. Она суммируется с другими скидками, поэтому, если давно присматривались к обучению, сейчас удобный момент оформить курс на более выгодных условиях.

А начать можно с открытых уроков по ключевым задачам:

  • 1 апреля в 20:00 — «Scala + Cats: аккумулируем ошибки с Validated без боли».
    Про валидацию данных и сбор всех ошибок сразу. [Записаться на урок]

  • 14 апреля в 20:00 — «Case classes и pattern matching: суперсила Scala на практике».
    Про чистую работу с данными и логикой. [Записаться на урок]

  • 20 апреля в 20:00 — «Эффекты в Scala».
    Про явную и безопасную работу с эффектами. [Записаться на урок]