Search
👻

팬텀타입 (Phantom Type)

(작성일: 2020-12-03) 팬텀타입이란 타입 매개변수가 타입 선언부의 왼쪽에만 존재하는 타입을 말합니다. 코드로 표현해보겠습니다.
type t<'a> = string type dog type cat let mike: t<dog> = "Mike" let marla: t<cat> = "Marla"
Reason
컴파일러는 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 */
Reason
위의 예에서 mate 함수는 t<'a> 타입의 두 개의 인자를 받아서 string을 변환하는 함수라고 Animal 모듈 타입에서 선언되었기 때문에, 다른 타입을 가진 mikemarla 에 대해 둘의 타입이 다르다고 컴파일이 되지 않습니다.
그렇다면 새로운 함수를 하나 추가해보겠습니다.
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
Reason
interMate 함수는 각각 t<'a>, t<'b> 타입을 가진 두 개의 인자를 받아서 string을 반환하는 함수로 정의되었기 때문에, mikemarla 를 인자로 넘겨 받아도 컴파일이 됩니다.
팬텀타입을 이용하면 하나의 타입(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 => (...) }
Reason
이렇게 구현한 FormData 모듈을 사용하겠습니다.
let shouldBeOkay = FormData.make("should be okay") let validatedData = FormData.validate(shouldBeOkay) FormData.saveToDB(validatedData)
Reason
팬텀타입을 이용해서 반드시 validate 함수를 거쳐야만, saveToDB 함수에 인자로 넘길 수 있도록 강제할 수 있습니다. 만약 validate 함수를 거치지 않는다면, 이 코드는 컴파일 되지 않을 것 입니다.
let cantBePassed = FormData.make("ok?") FormData.saveToDB(cantBePassed) // 컴파일 오류!
Reason
만약 여러분이 만든 FormData 모듈을 다른 동료 개발자가 함께 사용한다고 가정해보겠습니다. 그런 경우 동료 개발자가 실수로(?) 혹은 일부러 validate 를 우회하는 함수를 하나 만들어서 시도한다면 어떻게 될까요?
let byPass: string => FormData.t<FormData.validated> = a => a /* This has type: string Somewhere wanted: FormData.t<FormData.validated> */
Reason
다행히 컴파일 되지 않습니다. 왜냐하면 FormData 모듈 타입에서 선언한대로, string과 FormData.t<'a>는 엄연히 다른 타입이기 때문이죠. 물론 구현체인 FormData 모듈에서는 t<'a>를 string이라고 선언하고 makevalidate 함수를 구현하였지만, bypass 함수는 FormData 모듈 안에서 구현된 것이 아니기 때문에, bypass 함수에 대해 컴파일러는 모듈 타입에 따라 타입 오류라고 경고하는 것입니다.
팬텀타입을 이용해서 구분한 FormData.t<'a>를 사용하여 작동하는 validate , saveToDB 함수를 외부에서 조작한 값을 이용해서 사용할 수 없게 만든 것입니다.

마무리

팬텀타입에 대해 두 가지 예를 가지고 살펴보았습니다. 팬텀타입은 ReScript/ReasonML에만 있는 것이 아니라 Haskell, Rust, Swift 등 다른 언어에서도 활용할 수 있다고 합니다. 사실 팬텀타입은 타입 시스템을 이용한 기교에 속하기 때문에, 반드시 알아야만 하는 것은 아니지만, 타입을 이용해서 조금 더 재미있고 안전한 코드를 만들 수 있을 것 같습니다.

참고자료

TOP