Семантична типізація, яку ми ігноруємо

Семантична типізація, яку ми ігноруємо

Опубліковано 24.12.2025 | Востаннє оновлено 03.01.2026

У Kotlin ми постійно звужуємо наші типи. Ми віддаємо перевагу String замість Any для тексту, Int замість Any або навіть Number для цілих чисел. Це здається очевидним — майже тривіальним. Можна закінчувати, так?

Тож про що насправді ця стаття?

Хоча ми підсвідомо уникаємо Any у більшості ситуацій — з поважних причин, або іноді просто тому, що «навіщо мені ставити тут Any замість Int?» — ми часто не застосовуємо таке саме мислення, коли моделюємо наше власне програмне забезпечення: його типи, його поведінку та зв'язки між ними.

Ця стаття досліджує практику семантичної типізації — що це означає, чому ви можете цього хотіти, коли це покращує ваш код, і коли це стає перешкодою. Ми розглянемо, як семантична типізація проявляється в реальних проєктах Kotlin, як у коді застосунків, так і в дизайні бібліотек, і що зазвичай працює добре — або ні.

Ви вже звужуєте типи щодня; ця стаття про те, як робити це свідомо та вдумливо.

Ми виділимо з цих уроків практичні правила, які допоможуть вам вирішити, коли звужувати типи семантично — керуючись розумом, а не інтуїцією чи звичкою.

Що таке семантична типізація?

Почнемо з визначення того, про що ми насправді говоримо, чому це важливо, і чому це не те, що можна просто ігнорувати.

Семантична типізація — це практика створення семантично значущих підмножин типів загального призначення для вираження намірів та запобігання випадковому неправильному використанню. Це перехід від структурних типів («це рядок») до поведінкових або семантичних типів («це електронна пошта»).

Ідея семантичної типізації не є унікальною для Kotlin — вона давно є звичною практикою в таких мовах, як Java, де розробники визначають легкі класи-обгортки (наприклад, UserId, Email) навколо примітивів, щоб закодувати доменне значення та запобігти випадковому неправильному використанню.

Наприклад:

@JvmInline
value class EmailAddress(val raw: String) {...}

Варто зазначити, що семантична типізація не обмежується обгортанням примітивів; вона однаково стосується обгортання будь-якого типу — чи то просте Int, чи складна Map<K, V>. Основна ідея полягає в тому, щоб надати семантичне значення та сильнішу безпеку типів, розрізняючи значення за межами їхньої простої структури.

Чому це має вас хвилювати? Зазвичай саме тут з'являється скептицизм. Щоразу, коли згадується цей підхід, реакція часто звучить так:

Що я отримаю в обмін на цей додатковий код? Хіба це не просто більше шаблонного коду?

Це цілком слушні питання — це змушує вас інвестувати більше часу в роздуми про те, як моделювати ваш код, і загалом займає більше часу на написання. Я повністю за дотримання ідей KISS та уникнення проблеми неправильних абстракцій.. і тепер до великого АЛЕ! 🌚

Те, що ви отримуєте натомість, насправді не є абстракцією заради самої абстракції — це ясність. Давайте подивимося, що це означає на практиці.

Чому?

Безпека на рівні компіляції (Compile-time safety)

Давайте створимо приклад:

/**
 * @param value Ширина в незалежних від щільності пікселях (dp). 
 */
fun setWidth(value: Int) { ... }

На перший погляд, це виглядає нормально. Але легко випадково переплутати одиницю вимірювання при виклику:

val containerSize = 1000 // випадкове ціле число або просто значення в пікселях
card.setWidth(containerSize) // очікувалися dp

Код успішно компілюється і навіть може здаватися правильним, але тут криється велика помилка, яку ви не можете перевірити всередині функції.

Звужуючи типи, ми робимо неправильні виклики неможливими під час компіляції. Наприклад, ми можемо ввести семантичний тип Dp:

@JvmInline
value class Dp(public val raw: Int) {...}

/**
 * @param value Ширина в незалежних від щільності пікселях (dp). 
 */
fun setWidth(value: Dp) { ... }

Тепер, коли передається аргумент value, ми переконуємося, що місце виклику знає про одиницю вимірювання, яку ми очікуємо.

Документація

Я не єдиний, хто час від часу занадто ледачий, щоб перевіряти або писати документацію, правда? Але не звинувачуйте мене — як сказав один мудрець (Роберт С. Мартін у Clean Code):

Перш ніж додавати коментар, подумайте, чи можете ви рефакторити код так, щоб його намір був зрозумілим без нього. Коментарі, які просто повторюють те, що робить код, непотрібні; хороший код повинен говорити сам за себе.

Семантична типізація не просто дозволяє обернути значення в клас — вона дозволяє документувати сам тип. Ви точно передаєте, які дані очікуються, не повторюючи цю інформацію в кожній функції, яка їх використовує. Самодокументований код, зроблений правильно.

Повертаючись до нашого прикладу:

/**
 * @param value Ширина в незалежних від щільності пікселях (dp). 
 */
fun setWidth(value: Dp) { ... }

Ви можете вважати документацію трохи смішною — ніби ви можете просто передати щось інше замість Dp. Але окрім того, що цей коментар стає майже застарілим, ми також усуваємо іншу проблему: дублювання документації.

Подумайте про це: якщо у нас є setWidth, у нас, ймовірно, також є setHeight, setSpacing та подібні функції. Без семантичної типізації одна й та сама документація копіюється скрізь — або, що гірше, вона неповна, відсутня або повністю застаріла десь, тому що хтось був ледачим або просто забув. Тоді кожен, хто читає код, змушений вгадувати очікувані вхідні дані на основі інших частин коду, які вони, можливо, навіть не викликають. Зі звуженим типом ці здогадки зникають — ви просто повторно використовуєте тип там, де це семантично доречно.

Але це ще не все. Окрім «обгортання» даних, потрібно враховувати ідентичність та семантику. Ви не просто ліпите на нього випадкову назву, як ту, що GitHub запропонував для вашого репозиторію, ви надаєте йому справжнього значення. Тип повинен існувати самостійно, не вимагаючи додаткового контексту, щоб ви не отримали щось на зразок цього:

fun setWidth(dp: Int) { ... }
// проти
fun setWidth(dp: Size) { ... }

Де ви намагаєтеся виразити одиницю вимірювання через назву параметра. І документування функції замість цього теж не краще. Хто змушує вас або мене перевіряти назву параметра? Що, якщо після довгого робочого дня я помилково припустив, що вона приймає необроблені пікселі?

Навіть у другому прикладі ви все одно можете переплутати одиниці, фактично перетворюючи це на ще один Int, але просто обгорнутий і з трохи меншою кількістю недоліків (ми захистили його від того, щоб бути просто випадковим Int для випадкового Size). А старі версія з Int? Абсолютно загальна — вона каже нам щось і нічого водночас. Семантична типізація повинна і змушує код говорити, що він означає, голосно і чітко. Це те, що робить цей концепт потужним і самодокументованим.

Валідація

Ще одна перевага впровадження семантичної типізації — це валідація. Ми повертаємося до самодокументації, але цього разу увага зосереджена на повторному використанні.

У наших попередніх прикладах кожна функція повинна була індивідуально перевіряти незалежні від щільності пікселі, щоб швидко виявити помилку (сподіваюся, ми це робили, так?) і уникнути прихованих багів:

fun setWidth(dp: Int) {
	require(dp < 0) { "dp cannot be negative" }
	// ...
}

fun setHeight(dp: Int) {
	require(dp < 0) { "dp cannot be negative" }
	// ...
}
// ...

Тепер ми можемо перемістити цю логіку в сам тип, перетворивши його на справжній "підмножинний" тип:

@JvmInline
value class Dp(public val raw: Int) {
	init {
		require(dp < 0) { "dp cannot be negative" }
	}
}

Зверніть увагу, як це усуває повторну валідацію, гарантує коректність скрізь і чітко документує передбачені обмеження.

Примітка Ви, звичайно, можете ввести більш надійну валідацію, щоб бути більш типобезпечними залежно від того, де і як це використовується; Оскільки ця стаття стосується, в основному, семантичної типізації, а не валідації (та велика сама по собі), ви можете прочитати про валідацію окремо тут. 😄

Ми могли б фактично об'єднати Документацію та Валідацію в один розділ — але я навмисно залишив їх окремими. Чому? Тому що деякі виклики валідації є більш тонкими, і використання семантичного типу ідеально їх підкреслює.

Візьмемо приклад з реального світу, на який ми натякали у вступі:

@JvmInline
value class EmailAddress(val raw: String) {...}

Валідація електронної пошти завжди була головним болем. Різні частини системи можуть застосовувати різні правила, часто так, що ніхто цього не помічає. Одна функція може просто перевіряти, чи містить рядок @. Інша може використовувати спрощений регулярний вираз зі StackOverflow. Третя може намагатися реалізувати повний стандарт RFC.

Результат? Три функції, які семантично очікують однакові вхідні дані, можуть поводитися по-різному: одна приймає рядок, інші відхиляють його. Такі баги тонкі, їх важко зловити і дратує дебажити.

Звужуючи це до семантичного типу, ви централізуєте обмеження:

  • Будь-який екземпляр EmailAddress гарантовано є дійсним згідно з вашими правилами. І вони залишаються незмінними.
  • Споживачі типу не потребують повторення валідації.
  • Компілятор гарантує, що через вашу систему проходять лише дійсні дані.

Валідація + самодокументація + безпека часу компіляції: це сила семантичної типізації на практиці.

Коли використовувати?

Маючи на увазі приклади, ми можемо переходити до створення правила, коли нам дійсно слід використовувати семантичну типізацію, а коли, швидше за все, ні.

Залежно від вашого проєкту або вимог до коду рівня застосунку, це може відрізнятися, але не надто сильно. Ми точно не хочемо надмірно ускладнювати!

Код застосунку

Почнемо з того, на чому Kotlin зосереджується в першу чергу — код на рівні застосунку. Зазвичай у наших проєктах ми застосовуємо архітектурні патерни, такі як Clean Architecture, Domain-Driven Design, а іноді навіть Hexagonal Architecture. Всі вони мають спільний шар — домен — який у певній формі походить від DDD (чекніть мою статтю, якщо ви не знайомі з цим паттерном: Глибоке занурення в пошук правильного балансу між DDD, Clean та Hexagonal архітектурами).

У контексті доменних шарів ми зазвичай забезпечуємо виконання бізнес-правил, обмежень та загальної основної бізнес-логіки, ізольованої від інфраструктури або проблем застосунку. Оскільки домени призначені для відображення мови доменних експертів, зазвичай корисно вводити семантичну типізацію, і, зокрема, Value Objects (Об'єкти-значення).

Візьмемо приклад — агрегат User:

data class User(
	val id: Int,
	val email: String,
	val name: String,
	val bio: String,
)

Щоб забезпечити виконання бізнес-правил та обмежень, ви могли б піти кількома шляхами. Почнемо з протилежності семантичної типізації, просто щоб побачити, що може піти не так:

data class User(
	val id: Int,
	val email: String,
	val name: String,
	val bio: String?,
) {
	init {
		require(id >= 0)
		require(email.matches(emailRegex))
		require(name.length in 2..50)
		require(bio == null || bio.length in 0..500)
	}
}

Це не є неправильним підходом, але давайте поміркуємо, що тут може піти не так або здаватися неоптимальним.

Дублювання — одна очевидна проблема. Часто можна побачити кілька представлень однієї сутності (в даному випадку User) з однаковими властивостями, що змушує вас дублювати валідацію:

data class PublicUser(
	val id: Int,
	val name: String,
	val bio: String
) {
	init {
		require(id >= 0)
		require(name.length in 2..50)
		require(bio == null || bio.length in 0..500)
	}
}

Тут User містить повну інформацію для власника, тоді як PublicUser повертається всім іншим без електронної пошти. Окрім відчуття дискомфорту від дублювання, правила та обмеження мають тенденцію змінюватися з часом, що робить цей децентралізований підхід крихким і схильним до забування.

Рішення? Введіть Value Objects із семантичною типізацією. Кожна властивість стає типом, який включає в себе свої обмеження, централізуючи валідацію та роблячи вашу доменну модель самодокументованою:

@JvmInline
value class UserId(val raw: Int) {
    init { require(raw >= 0) }
}

@JvmInline
value class Email(val raw: String) {
    init { require(raw.matches(emailRegex)) }
}

@JvmInline
value class UserName(val raw: String) {
    init { require(raw.length in 2..50) }
}

@JvmInline
value class Bio(val raw: String?) {
    init { require(raw == null || raw.length in 0..500) }
}

data class User(
    val id: UserId,
    val email: Email,
    val name: UserName,
    val bio: Bio,
)

З таким підходом валідація централізована, дублювання зникає, а ваші доменні об'єкти стають самопояснювальними та безпечнішими за дизайном. Кожна властивість тепер несе семантичне значення, і будь-які зміни в правилах потрібно вносити лише в самому Value Object, а не розкидати по кількох класах.

Давайте також розглянемо більш складну структуру замість простого обгортання примітивів. Уявіть, що у нас є два обмежені контексти (функції):

  • Один відповідає за кошик покупок,
  • Інший відповідає за платежі.

Обидві функції поділяють одні й ті самі базові дані — вибрані продукти — але бізнес-правила відрізняються. Для кошика покупок допустимо мати порожній кошик, поки користувач все ще вибирає продукти. Однак для функції оплати важливо, щоб кошик був не порожнім — користувач повинен прийти з принаймні одним вибраним продуктом:

data class PaymentCart(
    val userId: Int,
    val products: List<Product>
) {
    init {
        require(userId >= 0)
        require(products.isNotEmpty()) // не повинен бути порожнім для оплати
    }
}

Тим часом, замість цього ви можете мати щось на зразок цього:

@JvmInline
value class ProductSelection(val raw: List<Product>) {
	// може бути щось більш надійне з патерном фабрики та типобезпечним результатом для зовнішнього шару, оскільки це частина введення користувача
    init { require(raw.isNotEmpty()) } // забезпечує непорожній список
}

data class PaymentCart(
    val userId: Int,
    val products: ProductSelection
)

Тепер тип сам гарантує обмеження: ви не можете створити ProductSelection з порожнім списком, усуваючи ризик забути про валідацію, якщо у нас, наприклад, є більше одного агрегату, як PaymentCart та Bill.

Ми також можемо надати ProductSelection додаткову поведінку. Наприклад, якщо певні кампанії обмежують вартість доставки залежно від придбаних продуктів:

@JvmInline
value class ProductSelection(val raw: List<Product>) {
    init { require(raw.isNotEmpty()) }
    
    val specialProducts: List<Product>
        get() = raw.filter { /* якась логіка фільтрації */ }
}

data class PaymentCart(
    val userId: Int,
    val products: ProductSelection
) {
    val deliveryCosts: Money
        get() = if (products.specialProducts.size >= 3) Money.ZERO else /* звичайна вартість */
}

Хоча ви могли б технічно реалізувати цю логіку всередині самого агрегату, локалізація обов'язків у Value Object (семантичний тип) спрощує код, робить API більш явним і залишає агрегат зосередженим на основній доменній логіці. І чому це має стосуватися лише коду в доменному шарі?

Код бібліотеки

Навіть якщо код бібліотеки часто є додатковим компонентом будь-якого застосунку — це означає, що він не завжди дотримується суворих правил, конвенцій або підходів, типових для коду застосунку (крім загальних найкращих практик) — я все одно наполегливо рекомендую використовувати той самий підхід для звуження ваших типів майже скрізь.

Код застосунку зазвичай пишеться з метою мінімізації початкових витрат. Це означає, що хоча може бути доцільним застосувати суворе моделювання в певних частинах (наприклад, у доменному шарі), щоб зменшити майбутні витрати на тестування та підтримку, підтримка такої самої суворої моделі скрізь часто здається непотрібною або надмірною. Однак, на мою думку, бібліотеки не підпадають під це обґрунтування «економії витрат».

Розглянемо наступний приклад:

@Serializable
sealed interface JsonRpcResponse<T> {
	val jsonrpc: JsonRpcVersion
	id: JsonRpcRequestId

	data class Success<T>(
		val jsonrpc: JsonRpcVersion,
		id: JsonRpcRequestId,
		val result: T,
	) : JsonRpcResponse<T>

	data class Error(
		val jsonrpc: JsonRpcVersion,
		id: JsonRpcRequestId,
		val error: JsonRpcResponseError,
	)
}

@Serializable
@JvmInline
value class JsonRpcVersion(val string: String) {
	companion object {
		val V2_0 = JsonRpcVersion("2.0")
	}
}

@Serializable
@JvmInline
value class JsonRpcRequestId(val jsonElement: JsonElement) {
    init {
        require(jsonElement is JsonNull || jsonElement is JsonPrimitive) {
            "JSON-RPC ID must be a primitive or null"
        }
        
        if (jsonElement is JsonPrimitive) {
            require(jsonElement.isString || jsonElement.intOrNull != null || jsonElement.longOrNull != null || jsonElement.doubleOrNull != null) {
                "JSON-RPC ID must be a string or number"
            }
        }
    }
    
    val isString: Boolean get() = jsonElement is JsonPrimitive && jsonElement.isString
    // ... інше
    val asString: String? get() = if (isString) (jsonElement as JsonPrimitive).content else null
    // ... інше
}

// ... інші класи таким же чином

Як ми бачимо, ми використовуємо семантичні типи майже скрізь! Давайте поміркуємо про це:

  • JsonRpcRequestId пов'язаний з тим, що має бути представлено в початковому запиті, що означає, що це вигідно для коду бібліотеки (менша схильність до помилок через людський фактор). Крім того, він представляє реальну концепцію, що походить зі специфікації.
  • JsonRpcVersion також повторно використовується, але він не так часто використовується, і загалом міркування інші — він насамперед представляє концепцію і робить тип самопояснювальним, посилаючись на специфікацію JsonRpc. Код, який його перевіряє, тепер знає, що він приймає JsonRpcVersion, а не щось інше.

Наприкінці я б сказав, що в більшості випадків найімовірніше вам потрібно створити семантичні типи, щоб провести чіткі межі заради вас та інших, але з винятками, про які ми поговоримо пізніше.

Приклади з реального світу

А тепер перейдемо до прикладів з реального світу, щоб побачити, де ми застосовуємо цю техніку, щоб поміркувати про них і розширити наш контекст.

Стандартна бібліотека

Візьміть Thread.sleep():

Thread.sleep(5) // п'ять чого? мілісекунд? секунд? хто знає

Одиниця вимірювання не є явною. Ви змушені вгадувати і знати це, і вручну конвертувати, якщо ви:

Thread.sleep(5 * 60_000) // тепер це п'ять хвилин, можливо

Деякі розробники можуть обернути це в java.time.Duration і конвертувати в мілісекунди на місці, тоді як інші просто пишуть необроблені числа і множать їх. Ніщо не змушує Вас бути послідовним. Кожен розробник пише те, що вважає правильним, і код стає майже випадковим. Одна помилка в конвертації, і поведінка тихо ламається. А складність коду? Уххххх.

Семантична типізація елегантно вирішує цю проблему. Наприклад, з типом Duration у Kotlin та добре спроєктованим API:

suspend fun delay(duration: Duration) {...}

suspend fun foo() {
	delay(5.minutes)
}

Набагато легше читати, чи не так?

Android Views проти Jetpack Compose

У класичних Android Views налаштування розмірів часто виглядає так:

val view = ImageView(context)

view.layoutParams = ViewGroup.LayoutParams(200, 300) // що це за числа? px чи dp? 

view.layoutParams = ViewGroup.LayoutParams(
    ViewGroup.LayoutParams.MATCH_PARENT,
    ViewGroup.LayoutParams.WRAP_CONTENT
)

Перша проблема полягає в тому, що на перший погляд і без читання документації ви не можете судити про вхідні параметри layoutParams — це 200 у пікселях? dp? Хто знає? Звичайно, в Android Framework більшість людей знають про це завдяки багатій документації та загальним знанням екосистеми. Але якщо ви створюєте власну бібліотеку або застосунок, ви не отримуєте такої ж гарантії. Ваш код, ймовірно, не дуже добре документований, і люди рідко запам'ятовують внутрішні деталі. Це призводить до помилкових припущень, багів і розчарування під час дебагу. Також, пам'ятаєте, що колись сказав один мудрець?

Друга проблема полягає в тому, що, просто дивлячись на клас ImageView, ви не маєте жодного уявлення, що ці константи існують або що вони належать саме до ViewGroup.LayoutParams. Хоча ImageView навіть не розширює цей клас, від вас очікується знання «магії» за MATCH_PARENT і WRAP_CONTENT. Для будь-якого новачка це кошмар — код, який технічно правильний, але абсолютно непрозорий.

Тепер подивіться на Jetpack Compose:

Box(
    modifier = Modifier
        .width(200.dp)
        .height(300.dp)
        .fillMaxWidth()
) {...}

Compose вирішує ту саму проблему через семантичну типізацію:

  • Dp обгортає необроблені числа, запобігаючи випадковому неправильному використанню.
  • Внутрішні розміри, такі як fillMaxWidth(), є типізованими функціями, а не магічними числами.
  • Компілятор гарантує коректність — більше жодних здогадок про одиниці чи константи.

Різниця разюча: Compose виштовхує коректність у систему типів, роблячи код самодокументованим, безпечнішим і легшим для розуміння, тоді як класичні Views покладаються на конвенції та ментальні перевірки.

Це ідеальна ілюстрація з реального світу, чому семантична типізація має значення: вона зменшує ментальне навантаження, запобігає неправильному використанню та передає наміри безпосередньо через типи.

Коли не варто використовувати це?

Думайте про семантичну типізацію в контексті об'єктів-значень з DDD – перш за все як про концепції з реального світу. У більшості випадків — чи будуєте ви застосунок, чи бібліотеку — типи, які ви визначаєте, повинні представляти речі, які існують або мають сенс у світі, який моделює ваше програмне забезпечення. Більшість значень, з якими ми працюємо, дійсно відповідають таким концепціям з реального життя. Однак легко почати оверінжинирити і почати моделювати правила замість ідей — що призводить до типів, які захаращують вашу кодову базу, а не прояснюють її.

Моделювання вхідних даних

Почнемо з чогось простого.

Візьміть PositiveNumber, який на перший погляд може здатися корисним: воно забезпечує виконання обмеження і може бути повторно використане в багатьох місцях. Але чи є це концепцією? Не зовсім.

Ви можете сказати:

Але навіщо перейматися? Хіба Ви не казали, що це перевага семантичної типізації — що я можу централізувати валідацію? Хіба не для цього існують назви змінних? Якщо я назву це price: PositiveNumber або distance: PositiveNumber, хіба це все не прояснить?

Можливо — на мить. Але самої назви недостатньо на практиці:

  • Назва не статична. Як тільки значення покидає область, де воно було створене, значення часто втрачається. Ви не можете покладатися на кожного розробника — у кожному файлі, шарі чи функції — щоб підтримувати цю ясність через назви змінних.
  • Люди повторно використовують типи для зручності. Якщо PositiveNumber підходить, хтось використає його для ціни, відстані, кількості товарів або будь-чого іншого, що їм підійде. І як тільки тип використовується загально, його початковий намір зникає. Ви не хочете створювати такий «загальний» тренд, інакше в чому сенс впровадження семантичного типу?
  • Онбординг страждає. Коли один і той самий тип використовується для багатьох речей, стає важче міркувати про код, важче відстежувати баги, і важче для новачків зрозуміти, що значення насправді представляє.
  • Баги підкрадаються тихо. Ви можете ніколи не помітити, що хтось випадково передав «довжину в кілометрах», де очікувалася «ціна в євро» — тому що тип PositiveNumber приймає обидва.

Тож проблема не в тому, що назви погані — проблема в тому, що їх занадто легко неправильно зрозуміти, занадто легко неправильно використати і занадто легко забути. Якщо щось означає ціну, зробіть це Price. Воно має пережити рефакторинг, межі та час. Отже, найпростіша причина уникати цього — тому що це занадто загально. Насправді Валідація — це лише зручний наслідок, а не причина для впровадження семантичної типізації.

Ви можете взяти природну мову як орієнтир і подумати, чи ви б висловили свої думки незнайомцю так, як ви формулюєте свій семантичний тип. Але, чесно кажучи, оскільки це занадто суб'єктивно, для мене це не підійшло як правило.

Тому ми можемо придумати більш інтуїтивне та більш явне правило:

Обмеження (валідація) типа не повинні керувати вашим семантичним типом у жодному сенсі — ні в назві, ні в існуванні.

Але що, якщо PositiveNumber був у математичному контексті?

Може здатися, що PositiveNumber все ще може допомогти запобігти помилкам у нашому коді, наприклад, у контексті математичної операції, яка використовує ділення:

fun sqrt(x: PositiveNumber): Double

На перший погляд, це виглядає охайно — ви вбудовуєте доменне правило «ніяких від'ємних значень» прямо в тип у контексті, який «дозволяє» таку концепцію.

Але чи є це валідною концепцією в такому домені? Я б, можливо, сказав «так», але точно б уникав цього підсвідомо. Чому?

Значення в такому випадку залежить від операції, а не від вхідних даних. Візьміть це:

fun sqrt(x: PositiveNumber): Foo

Саме операція (sqrt) визначає обмеження — що вхідні дані повинні бути невід'ємними. Самі вхідні дані, як число, не несуть цього значення за своєю суттю. Без контексту операції тип PositiveNumber є просто числом з обмеженням — але що він насправді означає сам по собі?

До того ж, багато людей асоціюють «позитивне число» з «невід'ємним», забуваючи, що нуль не є ні позитивним, ні негативним, ні тим і іншим одночасно. Ця тонка плутанина може призвести до начебто логічного коду, який насправді є неправильним. Концептуально подивіться на ці дві операції:

@JvmInline
value class PositiveNumber(val value: Double) {
    init {
        require(value > 0) { "Значення має бути строго позитивним" }
    }
}

fun sqrt(x: PositiveNumber): Foo
fun ln(x: PositiveNumber): Foo

Тепер уявіть, що ви намагаєтеся викликати ці функції:

val zero = PositiveNumber(0.0) // ❌ викидає IllegalArgumentException
sqrt(zero) // ❌ не вдається, але математично sqrt(0) є допустимим
ln(PositiveNumber(1.0)) // ✅ працює

Обидві функції, здається, оперують «позитивними числами», але точні обмеження відрізняються: sqrt приймає нуль (невід'ємні), тоді як ln вимагає строго позитивних значень. Якщо ви спробуєте повторно використати один тип PositiveNumber для обох, одна з операцій або відхилить дійсні вхідні дані, або дозволить недійсні. Семантична типізація повинна поважати контекст операції, а не лише номінальне значення.

Хоча приклад є дуже специфічним, він повинен дати вам чудовий приклад можливих проблем неправильного використання занадто загальних типів, що є причиною їх не вводити.

А початкова проблема? Ви переклали обмеження на вхідні дані на сематичний тип, але на практиці саме операція визначає обмеження. Ви могли б натомість сказати:

fun sqrt(a: Double): Double {
	// було б також більш ніж нормально мати тут просто guard (захисник)
	require(a >= 0)
	// ...
}
fun sqrtOrNull(a: Double): Double? {
	if (a > 0) {...}
	return null
}

fun ln(a: Double): Double {
	require(a > 0)
	// ...
}
fun lnOrNull(a: Double): Double? {
	if (a > 0) {...}
	return null
}

Віддавайте перевагу дублюванню перед неправильною абстракцією для випадків, коли ви не можете придумати концепцію з реального світу.

Тому я б створив ще більш явне правило:

Обмеження операції не повинні керувати вашим семантичним типом у жодному сенсі — ні в назві, ні в існуванні. Якщо ви не можете придумати розумну назву без згадки обмеження, яка б відповідала цьому правилу – вам, швидше за все, не потрібен «семантичний тип».

Ще одна річ, яку варто згадати, це те, що ми можемо, наприклад, мати клас Dp, який представляє незалежні від щільності пікселі, і все одно мати обмеження, визначені операцією (контекстом використання):

fun setDividerHeight(value: Dp) {
	require(value != Dp.ZERO)
	// ...
}

Введення якогось DividerHeightDp з такою умовою є надмірним і в основному підпадає під проблему PositiveNumber.

Але ви можете сказати:

Але що, якщо ми дозволимо PositiveNumber просто тому, що він забезпечує корисне обмеження, і у мене немає жодної двозначності в моєму коді щодо цього?

І це, можливо, чудово! Але де ми в реальності проводимо межу?

Чому зупинятися на цьому?

  • Чому б також не мати NegativeNumber?
  • NonZeroNumber?
  • NonEmptyList, OneElementList, AtLeastThreeItemsList?
  • ShortString, UppercaseString?

Я абсолютно впевнений, що принаймні деякі з наведених варіантів ви не хотіли б бачити у своєму коді, але без чіткого правила легко певною мірою попастися на це.

І як тільки ви починаєте йти цим шляхом, будь-яке правило стає кандидатом на семантичний тип — і в цьому проблема. Ви більше не моделюєте концепції — ви моделюєте умови. А умови не є ідентичностями.

У цей момент ви не проєктуєте багату доменну модель. Ви будуєте бібліотеку валідації обмежень у формі вашого домену. І це зовсім інша мета — та, яка вносить більше складності, ніж ясності.

Отже, давайте придумаємо розумне правило, не покладаючись на жодні з наших «внутрішніх відчуттів»:

Якщо семантичний тип не може існувати самостійно як значуща концепція без свого базового типу, це, ймовірно, поганий семантичний тип.

Це також причина, чому введення DividerHeight(value: Dp) замість DividerHeightDp(value: Int) – погана ідея. Він просто буде анврапитись щоразу, створюючи багато непотрібного шаблонного коду в нашому коді.

Моделювання вихідних даних

Ми витратили достатньо часу, говорячи про семантичну типізацію для вхідних даних — типів, які обгортають дані, що надходять у наш домен. Але як щодо протилежного напрямку? Чи можуть семантичні типи також бути корисними для моделювання вихідних даних?

Розглянемо наступний приклад:

val textContents: FileTextContents

Чому це погано? Перш за все, це вказує на джерело! Але чекайте.. чому це проблема?

Давайте додамо схожі варіанти, щоб краще зрозуміти цю проблему:

  • FileTextContents
  • HttpTextContents
  • UserInputTextContents
  • ..де ми зупинимося?

Цілком нормально мати наступне:

@JvmInline
value class FileSize(private val bytesAmount: Long) {
	val bytes: Long get() = bytesAmount
	val kilobytes: Long get() = ...
	// ...
}

class File (...)
	// ...

	val size: FileSize get() = ...

	// ...

Цей приклад насправді не про те, щоб вказати джерело даних, а скоріше про функціональну концепцію саму по собі — розмір файлу. Ідея FileSize представляє фактичну концепцію: кількість байтів, названу відповідно до нашого домену. Чи прийшла вона з файлу, буфера пам'яті або мережевого потоку — її значення залишається незмінним. FileSize відображає концепцію, яка існує сама по собі.

Отже, називати це DownloadedFileSize не має сенсу. Запитайте себе:

Чи справді джерело цього рядка має значення для бізнес-логіки?

Введення чогось подібного може здаватися більш «явним», але насправді ви жорстко прив'язуєте контекст, який не належить до основної концепції. Ви берете нейтральну, повторно використовувану концепцію — як-от «текст» або «розмір» — і без потреби звужуєте її сферу застосування. Це створює фрагментацію, плутанину та непотрібні церемонії в API перетворення одного в інше без причини. І я маю відчуття, що ми не любимо мапити одне в інше 🌚

Називайте свою концепцію достатньо точно, щоб її можна було зрозуміти з першого погляду, але без перебільшення.

Висновок

Семантична типізація, і зокрема об'єкти-значення (Value Objects), є потужними інструментами для впровадження намірів, валідації та доменного значення в типи. Використовуйте їх, коли концепція є частиною вашої мови домену, коли інваріанти повинні бути централізовані або коли межа API потребує чіткішого наміру. Уникайте їх, коли обмеження визначається операцією, а не самою концепцією, або коли тип буде надмірно загальним і, таким чином, втратить значення. Встановлюйте прості командні правила, щоб переваги масштабувалися без створення непотрібних церемоній.