타입스크립트를 이용해서 타입 제한 추가하기


개요

타입스크립트의 타입 시스템은 보통 내가 짜는 코드가 정해진 타입을 준수하는지 확인하는 용도로 주로 쓰이지만, 타입이 정해진 조건을 만족하는지 판단하는 용도로도 사용할 수 있다. 이번 포스팅에서는 타입 제한을 추가해서 브라우저 확장의 메시지 패싱 시스템을 만족하는 코드가 될 수 있도록 강제하는 방법에 대해 다룬다.

Ver2: 일부 타입 체크 부분을 좀 더 간단하게 구현할 수 있어서 수정함

목표

내가 정의한 타입이 미리 준비된 타입 제한을 통과하지 못하면 컴파일 타임에 에러가 나도록 한다.

배경

브라우저 확장의 메시지 패싱은 여러 javascript 인스턴스 사이의 통신을 가능하게 해주는 기능이다. 당연히 메시지가 전달되는 과정에서 serialization이 일어난다고 추측할 수 있고, 따라서 메시지 내부 데이터에 콜백같은 함수를 넣으면 될 리가… 없다.

근데 내가 얼마전에 작성한 메시지 패싱을 이용한 RPC 구현을 모르는 사람이 보면, 그냥 인터페이스를 따라 코드를 짜면 다 될것처럼 생겨먹었다. 파라미터에 Promise를 넣는다던가, 반환값이 Promise가 아닌 value가 바로 반환된다고 정의한다던가… 당연히 안된다고 한 적이 없으니 되는줄 알 수도 있겠다는 것이다.

이런 상황을 막으라고 타입 시스템이 있는 것일테니, 어떻게 타입을 강제해서 비벼보려고 마음먹었다.

결과물

여러가지 삽질을 했지만, 중간 과정은 생략한다. 결과물은 대충 이런식이다.

export function assertTrue<T extends true>(): T {
  return true as T;
}

export function assertFalse<T extends false>(): T {
  return false as T;
}

// Define primitive types which are serializable
type Json = string | number | boolean | null | Json[] | { [key: string]: Json; };
// Check if the function meets the serialization requirements
//   - Arguments is serializable
//   - Return value is either void or Promise<Json | void>
// becomes never if check fails
type CheckFunc<F> = F extends (...args: infer T) => void | Promise<Json | void> ? (T extends Json[] ? F : never) : never;

assertTrue<CheckFunc<(p1: number, p2: string, p3: boolean) => Promise<string>> extends never ? false : true>();
assertTrue<CheckFunc<(p1: number, p2: string, p3: null) => Promise<number>> extends never ? false : true>();
assertTrue<CheckFunc<(p1: number, p2: string, p3: { num: number, str: string; }) => Promise<boolean>> extends never ? false : true>();
assertTrue<CheckFunc<(p1: string, p2: [string, number, boolean]) => Promise<string>> extends never ? false : true>();
assertTrue<CheckFunc<() => void> extends never ? false : true>();
assertTrue<CheckFunc<() => void> extends never ? false : true>();
assertFalse<CheckFunc<(p1: number, p2: string, p3: () => {}) => Promise<string>> extends never ? false : true>();
assertFalse<CheckFunc<(p1: number, p2: string, p3: { fn: () => {}; }) => Promise<string>> extends never ? false : true>();
assertFalse<CheckFunc<(p1: number, p2: string, p3: [string, () => {}]) => Promise<string>> extends never ? false : true>();
assertFalse<CheckFunc<(p1: Promise<string>) => Promise<string>> extends never ? false : true>();
assertFalse<CheckFunc<() => string> extends never ? false : true>();
assertFalse<CheckFunc<() => Promise<() => {}>> extends never ? false : true>();

// Check interface: all of its member functions should meet CheckFunc condition, otherwise it becomes never
export type CheckInterface<T> = T extends { [K in keyof T]: CheckFunc<T[K]>; } ? T : never;

interface TI {
  func1(p1: number, p2: string, p3: boolean): Promise<string>;
  func2(): void;
  func3(): Promise<number>;
}

// Example checks
assertTrue<CheckInterface<TI> extends never ? false : true>();
assertTrue<CheckInterface<{ func1(): Promise<string>; }> extends never ? false : true>();
assertTrue<CheckInterface<{ func1(): Promise<number>; }> extends never ? false : true>();
assertTrue<CheckInterface<{ func1(): Promise<void>; }> extends never ? false : true>();
assertTrue<CheckInterface<{ func1(): Promise<null>; }> extends never ? false : true>();
assertFalse<CheckInterface<{ func1(): null; }> extends never ? false : true>();
assertFalse<CheckInterface<{ func1(): Promise<Date>; }> extends never ? false : true>();
assertFalse<CheckInterface<{ func1(): Promise<Function>; }> extends never ? false : true>();

컴파일 결과로 여러개의 assertTrue(); 호출이 생성되니 테스트 코드는 따로 분리하고 컴파일 결과에 포함되지 않도록 하는게 좋다. 나는 테스트용 tsconfig 파일과 빌드 결과물 생성용 tsconfig 파일을 따로 만드는 방법으로 해결했다.

결론

잘 된다. 오늘도 RPC를 빠르게 만들어서 브라우저 확장에 잘 추가했다.