* 이 프로젝트는 기본적으로 TypeScript를 써 보았고, 내장된 유틸리티 타입들과 Generic 등을 알고 있는 상태에서 진행하는 것을 추천합니다. 그렇지 않다면, 공식 사이트 등을 참고해가며 학습과 병행하는 것을 추천합니다.
지난 글(TypeGuards와 Narrowing)에서 언급했던 Opaque 타입에 대해 알아보겠습니다.
소개
우선 타입스크립트의 타입은 기본적으로 유형의 구조가 동일하다면 호환 가능합니다.
코드와 함께 살펴보겠습니다.
type Username = string;
type Password = string;
Username 타입과 Password 타입은 모두 string이기 때문에 타입스크립트에서는 호환 가능합니다.
개발자는 다른 개념으로 각각의 타입을 생성했지만 타입스크립트는 그 의미는 중요하지 않고 타입만을 검사할 뿐입니다.
// accounting.ts
export type AccountNumber = number;
export type AccountBalance = number;
export type PaymentAmount = number;
type Account = {
accountNumber: AccountNumber,
balance: AccountBalance
};
export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
const account = getAccount(accountNumber);
account.balance -= amount;
}
// handleRequest.ts
import {spend} from "./accounting";
type Request = {
body: {
accountNumber: number,
amount: number
}
};
export default function handleRequest(req: Request) {
const {accountNumber, amount} = req.body;
spend(amount, accountNumber);
}
위 예제에서는 handleRequest에서 spend함수의 매개변수 순서를 혼동하여 문제가 발생할 수 있음을 보여줍니다.
이 코드가 만약 배포가 되고 런타임에서 어떤 문제를 초래할지 모릅니다.
타입스크립트의 컴파일 단계에서 이를 잡아낼 방법은 없을까요?
Opaque Type
Opaque는 앞선 문제에서 발생할 수 있는 문제들을 해결하는데 도움을 줍니다.
만들고자 하는 Type과 함께 fake property를 Intersection Type을 통해 타입을 생성해냅니다.
// Opaque Type으로 변경
export type AccountNumber = number & { _t: 'AccountNumber' };
export type PaymentAmount = number & { _t: 'AccountBalance' };
이전의 AccountNumber와 PaymentAmount를 Opaque타입으로 변경해보았습니다.
변경하고 난 후 spend 함수에서는 number타입은 Accountnumber에 할당할 수 없다는 에러가 발생하게 됩니다.
// handleRequest.ts 수정
type Request = {
body: {
accountNumber: AccountNumber;
amount: PaymentAmount;
};
};
export default function handleRequest(req: Request) {
const { accountNumber, amount } = req.body;
spend(accountNumber, amount);
}
Opaque 타입으로 변경하며 이전 코드에서 잡히지 않던 에러를 처리할 수 있었습니다.
Opaque를 Generic을 활용해 생성하기 쉽게 만들어 볼 수 있습니다.
// Opaque.ts
declare const tag: unique symbol;
export type Opaque<Type, Token = unknown> = Type & {
readonly [tag]: Token;
}
// accounting.ts
export type AccountNumber = Opaque<number, 'AccountNumber'>;
export type AccountBalance = Opaque<number, 'AccountBalance'>;
export type PaymentAmount = Opaque<number, 'PaymentAmount'>;
export function spend(accountNumber: AccountNumber, amount: PaymentAmount) {
const account = getAccount(accountNumber);
account.balance = (account.balance - amount) as AccountBalance;
}
Generic을 활용해 Opaque라는 유틸리티 타입을 생성하니 조금 더 간결하게 만들 수 있게 되었습니다.
Opaque를 해제하고 원형을 가져오기 위한 유틸리티 타입도 만들어봅시다.
declare const tag: unique symbol;
type Tagged<Token> = {
readonly [tag]: Token;
};
export type Opaque<Type, Token = unknown> = Type & Tagged<Token>;
export type UnOpaque<OpaqueType extends Tagged<unknown>> =
OpaqueType extends Opaque<infer Type, OpaqueType[typeof tag]>
? Type
: OpaqueType;
결과는 다음과 같습니다.
type AccountNumber = Opaque<number, 'AccountNumber'>;
// 추론되는 타입은 모두 풀어 써놓은 것입니다.
declare const t1: Opaque<AccountNumber>; // number & { [tag]: 'AccountNumber' };
declare const t2: Opaque<number>; // number & { [tag]: unknown };
declare const t3: UnOpaque<AccountNumber>; // number
declare const t4: UnOpaque<string>; // string
결론: Opaque의 활용
Opaque는 컴파일 타임에만 존재하는 가짜 프로퍼티 타입을 통해 생성된 Opaque는 그 원형에 할당할 수 있지만, 반대의 경우는 할 수 없습니다.
Opaque 타입을 사용하게 되면 얻는 주요 이점 중 하나는 단순한 문자열이 아니라 의미론적으로 의미를 지닌 특정 종류의 타입으로써 존재하기 때문에 코드 전체에서 특정 값의 흐름을 추적하기 쉬워진다는 것입니다.
따라서, 용도에 따라 Opaque타입을 적절히 사용한다면 리팩터링과 디버깅이 훨씬 안전하고 쉬워질 수 있습니다.
단, type assertion을 통해 Opaque타입으로 단언하는 부분은 항시 주의해야 합니다.
참고
'개발 > type-challenges' 카테고리의 다른 글
타입스크립트 타입 정복:타입의 구분 (0) | 2022.10.29 |
---|---|
타입스크립트 타입 정복: Polymorphic Function (0) | 2022.09.23 |
타입스크립트 타입 정복: Type Guards와 Narrowing (0) | 2022.08.02 |
타입스크립트 타입 정복: Conditional Types (0) | 2022.07.12 |
타입스크립트 타입 정복: 내장 유틸리티 타입을 만들어보자 (0) | 2022.07.02 |