TypeScript로 엔티티를 모델링할 때, 이와 같은 인터페이스를 얻는 것은 매우 일반적입니다.
interface User {
id: number
username: string
}
interface Order {
id: number
userId: number
title: string
year: number
month: number
day: number
amount: { currency: 'EUR' | 'USD', value: number }
}
문제점
속성의 타입은 의미론적 의미가 없습니다. 타입 측면에서 User.id
, Order.id
, Order.year
등은 모두 동일합니다: 숫자이며, 숫자로서 상호 교환 가능하지만, 의미상으로는 그렇지 않습니다.
앞의 예를 따라, 엔티티에 대해 작업을 수행하는 함수 집합을 가질 수 있습니다. 예를 들어:
function getOrdersFiltered(userId: number, year: number, month: number, day: number, amount: number) { // ...}
function deleteOrder(id: number) { // ... }
이 함수들은 숫자의 의미론적 의미에 관계없이 어떤 인수로든 숫자를 받아들입니다. 예를 들어:
const id = getUserId()
deleteOrder(id)
당연히, 이는 큰 실수이며, 코드를 읽지 않고 피하기 쉬워 보일 수 있지만, 코드가 항상 예제처럼 단순하지는 않습니다.
같은 일이 getOrdersFiltered
에도 발생합니다: 우리는 일과 월의 값을 바꿀 수 있으며, 경고나 오류를 받지 않습니다. 오류는 일이 12보다 클 때 발생하지만, 결과가 기대한 대로 되지 않을 것은 분명합니다.
해결책
객체 칼리스테닉스의 규칙은 이를 위한 해결책을 제공합니다: 모든 원시 데이터와 문자열을 래핑합니다 (관련 원시 집착 반패턴). 규칙은 원시 데이터를 의미론적 의미를 나타내는 객체로 래핑하는 것입니다 (DDD에서는 이를 ValueObjects
로 설명합니다).
하지만 TypeScript에서는 이를 위해 클래스나 객체를 사용할 필요가 없습니다: 우리는 타입 시스템을 사용하여 연도를 나타내는 것과 다른 숫자가 연도를 대신 사용할 수 없도록 보장할 수 있습니다.
브랜디드 타입
이 패턴은 의미론적 의미를 보장하는 속성을 추가하기 위해 타입의 확장성을 사용합니다:
type Year = number & { __brand: 'year' }
이 간단한 라인은 숫자로 작동할 수 있는 새로운 유형을 만들지만 숫자가 아니라 연도입니다.
const year = 2012 as Year
function age(year: Year): number { //... }
age(2012) // ❌ IDE will show an error as 2012 is not a Year
age(year) // ✅
해결책 일반화
브랜드 유형마다 유형을 작성하는 것을 피하기 위해 다음과 같이 유틸리티 유형을 만들 수 있습니다:
declare const __brand: unique symbol
export type Branded<T, B> = T & { [__brand]: B }
브랜드 속성 이름으로 고유한 심볼을 사용하여 속성과 충돌을 피하고 원래 유형과 브랜드를 일반 매개변수로 사용하는 유틸리티 유형입니다.
이를 통해 모델과 함수를 다음과 같이 리팩터링할 수 있습니다:
type UserId = Branded<number, 'UserId'>
type OrderId = Branded<number, 'OrderId'>
type Year = Branded<number, 'Year'>
type Month = Branded<number, 'Month'>
type Day = Branded<number, 'Day'>
type Amount = Branded<{ currency: 'EUR' | 'USD', value: number}, 'Amount'>
interface User {
id: UserId
username: string
}
interface Order {
id: OrderId
userId: UserId
title: string
year: Year
month: Month
day: Day
amount: Amount
}
function getOrdersFiltered(userId: UserId, year: Year, month: Month, day: Day, amount: Amount) { // ...}
function deleteOrder(id: OrderId) { // ... }
이제 이 예에서 IDE는 id
가 UserId
이고 deleteOrder
가 OrderId
를 예상하는 것으로 오류가 표시됩니다.
const id = getUserId()
deleteOrder(id) // ❌ IDE will show an error as id is UserID and deleteOrder expects OrderId
Trade-Offs
작은 희생으로 Brand
로 X
를 사용해야 합니다. 예를 들어, 원시값에서 새 값 생성 시 const year = 2012 as Year
를 사용해야 하지만 값 객체를 사용하는 경우 new Year(2012)
와 동등합니다. “생성자” 역할을 하는 함수를 제공할 수 있습니다:
function year(year: number): Year {
return year as Year
}
브랜드 유형으로 유효성 검사
브랜드 유형은 데이터가 유효한지 확인하는 데 유용하며 유효성이 검증된 데이터에 대한 특정 유형을 가질 수 있기 때문에 사용자가 유효성이 검증되었음을 신뢰할 수 있습니다:
type User = { id: UserId, email: Email}
type ValidUser = Readonly<Brand<User, 'ValidUser'>>
function validateUser(user: User): ValidUser {
// Checks if user is in the database
if (!/* logic to check the user is in database */) {
throw new InvalidUser()
}
return user as ValidUser
}
// We can not pass just a User, needs to be a ValidUser
function doSomethingWithAValidUser(user: ValidUser) {
}
Readonly
는 필수는 아니지만 데이터를 유효성 검사한 후에 데이터가 변경되지 않도록 보장하려면 매우 권장됩니다.
요약
브랜드 유형은 다음을 포함하는 간단한 해결책입니다:
- 코드 가독성 향상: 각 인수에 어떤 값이 사용되어야 하는지 명확해집니다.
- 신뢰성: 코드에서 감지하기 어려운 실수를 피하는 데 도움이 됩니다; 이제 IDE(및 타입 검사)가 값이 올바른 위치에 있는지 감지하는 데 도움을 줍니다.
- 데이터 검증: 브랜드 타입을 사용하여 데이터가 유효한지 확인할 수 있습니다.
브랜드 타입은 ValueObjects
의 일종의 버전으로 생각할 수 있지만, 클래스를 사용하지 않고 단지 타입과 함수로만 구성됩니다.
타입의 힘을 즐기세요!
Source:
https://dzone.com/articles/branded-types-in-typescript