Привет, Хабр! Классическая ошибка: функция принимает 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.
Альтернативы
Type alias (
type UserId = Int): никакой безопасности.UserIdиIntвзаимозаменяемы везде. Компилятор не поймает ошибку.Case class (
case class UserId(value: Int)): полная безопасность, но runtime‑объект. Boxing в дженериках, аллокации, GC.AnyVal (
class UserId(val value: Int) extends AnyVal): попытка убрать overhead, но боксится в коллекциях (List[UserId]), дженериках, при паттерн‑матчинге, при присвоении вAny. На практике у нас AnyVal боксится чаще, чем хотелось бы.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».
Про явную и безопасную работу с эффектами. ➡[Записаться на урок]
