팬텀타입(Phantom type)이란?

Page content

팬텀타입이란?

팬텀타입이란 타입 매개변수가 타입 선언부의 왼쪽에만 존재하는 타입을 말합니다. 코드로 표현해보겠습니다.

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 모듈 타입에서 선언되었기 때문에, 다른 타입을 가진 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

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 => (...)
}

이렇게 구현한 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이라고 선언하고 makevalidate 함수를 구현하였지만, bypass 함수는 FormData 모듈 안에서 구현된 것이 아니기 때문에, bypass 함수에 대해 컴파일러는 모듈 타입에 따라 타입 오류라고 경고하는 것입니다.

팬텀타입을 이용해서 구분한 FormData.t<‘a>를 사용하여 작동하는 validate, saveToDB 함수를 외부에서 조작한 값을 이용해서 사용할 수 없게 만든 것입니다.

마무리

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

참고자료