팬텀타입(Phantom type)이란?
팬텀타입이란?
팬텀타입이란 타입 매개변수가 타입 선언부의 왼쪽에만 존재하는 타입을 말합니다. 코드로 표현해보겠습니다.
type t<'a> = string
type dog
type cat
let mike: t<dog> = "Mike"
let marla: t<cat> = "Marla"
컴파일러는 mike: t<dog>
과 marla: t<cat>
을 각각 다른 타입으로 처리합니다. 실제로는 string 타입에 해당하는 문자열을 담고 있지만요. 이렇게 타입 매개변수가 선언부의 왼쪽에만 존재하여 다른 타입으로 처리할 수 있는 타입을 팬텀타입이라고 합니다.
module type Animal = {
type t<'a>
type dog
type cat
let makeDog: string => t<dog>
let makeCat: string => t<cat>
let mate: (t<'a>, t<'a>) => string
}
module Animal: Animal = {
type t<'a> = string
type dog
type cat
let makeDog = a => a
let makeCat = a => a
let mate = (a, b) => j`${a}와 ${b}는 이제 친구`
}
let mike = Animal.makeDog("Mike");
let marla = Animal.makeCat("Marla");
Js.Console.log(Animal.mate(mike, marla)) // Error
/**
This has type: Animal.t<Animal.cat>
Somewhere wanted: Animal.t<Animal.dog>
The incompatible parts:
Animal.cat vs Animal.dog
*/
위의 예에서 mate
함수는 t<‘a> 타입의 두 개의 인자를 받아서 string을 변환하는 함수라고 Animal
모듈 타입에서 선언되었기 때문에, 다른 타입을 가진 mike
와 marla
에 대해 둘의 타입이 다르기 때문에 컴파일이 되지 않습니다.
그렇다면 새로운 함수를 하나 추가해보겠습니다.
module type Animal = {
...
let interMate: (t<'a>, t<'b>) => string
};
module Animal = {
let interMate = (a, b) => j`${a}와 ${b}는 이제 친구`
}
Js.Console.log(Animal.interMate(mike, marla)) // Ok
interMate
함수는 각각 t<‘a>, t<‘b> 타입을 가진 두 개의 인자를 받아서 string을 반환하는 함수로 정의되었기 때문에, mike
와 marla
를 인자로 넘겨 받아도 컴파일이 됩니다.
팬텀타입을 이용하면 하나의 타입(string
)에 대해 복수의 서브타입 (t<'a>
, t<'b>
)을 가질 수 있는 효과를 얻을 수 있습니다.
요약하면,
- 타입 매개변수를 가지지만 선언부의 왼쪽에만 있는 타입이다.
- 하나의 데이터 표현(여기서는 string인 mike, marla)에 대해, 서브타입을 가질 수 있다.
활용 예
앞서 설명한 Animal의 예는 팬텀타입을 설명하는 참고자료에서 자주 인용되는 예 입니다. 조금 더 구체적인 예를 하나 들어보겠습니다.
어플리케이션을 만들 때 폼(form) 데이터를 많이 사용하게 되는데요. 이 폼 데이터를 검증하는 부분을 팬텀타입을 이용해서 구현해보겠습니다.
module type FormData = {
type t<'a>
type validated
type unvalidated
let make: string => t<unvalidated>
let validate: t<unvalidated> => t<validated>
let saveToDB: t<validated> => unit
}
module FormData: FormData = {
type t<'a> = string
type validated
type unvalidated
let make = a => a
let validate = a => a
let saveToDB = a => (...)
}
이렇게 구현한 FormData 모듈을 사용해겠습니다.
let shouldBeOkay = FormData.make("should be okay")
let validatedData = FormData.validate(shouldBeOkay)
FormData.saveToDB(validatedData)
팬텀타입을 이용해서 반드시 validate
함수를 거쳐야만, saveToDB
함수에 인자로 넘길 수 있도록 강제할 수 있습니다. 만약 validate
함수를 거치지 않는다면, 이 코드는 컴파일 되지 않을 것 입니다.
let cantBePassed = FormData.make("ok?")
FormData.saveToDB(cantBePassed) // 컴파일 오류!
만약 여러분이 만든 FormData 모듈을 다른 동료 개발자가 함께 사용한다고 가정해보겠습니다. 그런 경우 동료 개발자가 실수로(?) 혹은 일부러 validate
를 우회하는 함수를 하나 만들어서 시도한다면 어떻게 될까요?
let byPass: string => FormData.t<FormData.validated> = a => a
/*
This has type: string
Somewhere wanted: FormData.t<FormData.validated>
*/
다행히 컴파일 되지 않습니다. 왜냐하면 FormData 모듈 타입에서 선언한대로, string과 FormData.t<‘a>는 엄연히 다른 타입이기 때문이죠. 물론 구현체인 FormData 모듈에서는 t<‘a>를 string이라고 선언하고 make
나 validate
함수를 구현하였지만, bypass 함수는 FormData 모듈 안에서 구현된 것이 아니기 때문에, bypass 함수에 대해 컴파일러는 모듈 타입에 따라 타입 오류라고 경고하는 것입니다.
팬텀타입을 이용해서 구분한 FormData.t<‘a>를 사용하여 작동하는 validate
, saveToDB
함수를 외부에서 조작한 값을 이용해서 사용할 수 없게 만든 것입니다.
마무리
팬텀타입에 대해 두 가지 예를 가지고 살펴보았습니다. 팬텀타입은 ReScript/ReasonML에만 있는 것이 아니라 Haskell, Rust, Swift 등 다른 언어에서도 활용할 수 있다고 합니다. 사실 팬텀타입은 타입 시스템을 이용한 기교에 속하기 때문에, 반드시 알아야만 하는 것은 아니지만, 타입을 이용해서 조금 더 재미있고 안전한 코드를 만들 수 있을 것 같습니다.