사내에서 진행했던 <자동접수>에서 타입스크립트를 어떻게 활용했는가 세미나를 정리했다. 예시 코드는 <자동접수>의 코드를 각색했다.

# Content

# Case of Application

우선 타입스크립트에 대해 짤막하게 얘기하자면, 타입스크립트는 type을 꽤 자세히 표현할 수 있다는 장점이 있다. 하지만 코드뿐만 아니라 type도 자세히 정의하며 많은 시간을 소요하게 된다. 타입스크립트를 적당히 사용한다는 것은 컴파일 타임에 버그를 예방하기 위한 시간과 런타임에 디버깅하는 시간의 trade-off 라고 생각한다. 타입스크립트를 이제 막 익혔다면, 아직 런타임에 디버깅하는 것이 더 효율적일 수 있다. 하지만 동료들과 협업하다 보면 대화와 문서로 공유하는 것보다 타입스크립트로 공유하는 것이 효율적일 것이다. 본 세미나를 통해서 타입스크립트로 동료들과 어떻게 소통하고 공유할 수 있는지 알고, 상황에 맞게 타입스크립트를 적재적소에 적용할 수 있길 바란다.

No type casting, use type predicates

Problem

// type of event.target.value, the parameter of onChange, is string. You cannot fix it.
const fooBar = ['foo', 'bar'] as const;
type FooBar = typeof fooBar[number];

function FooOrBar() {
  const [item, setItem] = useState<FooBar>('foo');

  return (
    <Select
      value={item}
      onChange={({ target: { value } }) => setItem(value)}  <- TS error
    >
      {fooBar.map<ReactNode>((value) => (
        <MenuItem value={value} key={value}>
          {value}
        </MenuItem>
      ))}
    </Select>
  );
}

Select 컴포넌트의 onChange속성은 매개변수로 event를 넘겨주는데, event.target.valuetypestring이다. itemtypesetItem 매개변수의 typeFooBar기 때문에 setItem의 인자로 event.target.value를 넘겨줄 수 없다.

Solution1

// type of event.target.value, the parameter of onChange, is string. You cannot fix it.
const fooBar = ['foo', 'bar'] as const;
type FooBar = typeof fooBar[number];

function FooOrBar() {
  const [item, setItem] = useState<FooBar>('foo');

  return (
    <Select
      value={item}
      onChange={({ target: { value } }) => setItem(value as FooBar)}
    >
      {fooBar.map<ReactNode>((value) => (
        <MenuItem value={value} key={value}>
          {value}
        </MenuItem>
      ))}
    </Select>
  );
}

가장 간단한 방법은 event.target.valueFooBar로 형변환하는 것이다. 하지만 event.target.value가 정말 'foo' 또는 'bar' 일까? 만약 아니라면 어떻게 처리해야 할까?

Solution2

// type of event.target.value, the parameter of onChange, is string. You cannot fix it.
const fooBar = ['foo', 'bar'] as const;
type FooBar = typeof fooBar[number];

function FooOrBar() {
  const [item, setItem] = useState<FooBar>('foo');

  return (
    <Select
      value={item}
      onChange={({ target: { value } }) => isFooBar(value) && setItem(value)}
    >
      {fooBar.map<ReactNode>((value) => (
        <MenuItem value={value} key={value}>
          {value}
        </MenuItem>
      ))}
    </Select>
  );
}

function isFooBar(something: unknown): something is FooBar {
  // allow type assertion in function using type predicate
  return fooBar.includes(something as FooBar);
}

<자동접수>에서는 type predicates를 이용하여 event.target.valuetype을 추론한다. 형변환에 비해 보일러 플레이트가 많지만, 그만큼 안전한 코드를 작성할 수 있다. 타입스크립트는 컴파일 타임에만 작동하기 때문에 실제로 다른 type의 값이 전달될 수 있으며, 따라서 무리한 형변환은 오히려 버그를 찾기 어렵고 개발자를 헷갈리게 한다. 얽혀있는 내용이 많다면 형변환 대신 type predicates 함수를 사용하는 것을 권장하며, <자동접수>는 대부분의 상황에서 type predicates 함수를 사용한다. type predicates의 내용은 링크에서 확인하길 바라며, type predicates 뿐만 아니라 narrowing 챕터에서 type을 추론하는 내용을 다루고 있으니 읽어보는 것을 추천한다.

QuestionnaireType - type manipulation

Problem

  1. 설문지Questionnaire는 여러 질문Question으로 구성되어있다.
  2. 질문Question의 종류는 단수 선택 객관식SINGLE_CHOICE, 복수 선택 객관식MULTIPLE_CHOICE, 단답식NARRATIVE, 이미지형IMAGE 4가지이다.
  3. 객관식 질문(SINGLE_CHOICEMULTIPLE_CHOICE)에는 여러 개의 문항Item이 존재하며 문항Item의 종류는 평문TEXT, 단답INPUT, 이미지IMAGE 3가지가 있다.
  4. 단답식 질문NARRATIVE과 이미지형 질문IMAGE 도 문항Item이 존재하며 문항Item의 종류는 각각 INPUTIMAGE 이다.
  5. 설문지Questionnaire, 질문Question, 문항Item 문항은 고유한 키가 있다.

Solution1

type ItemType = 'TEXT' | 'INPUT' | 'IMAGE';
type QuestionType =
  | 'SINGLE_CHOICE'
  | 'MULTIPLE_CHOICE'
  | 'NARRATIVE'
  | 'IMAGE';

type Item = {
  key: number;
  itemType: ItemType;
};

type Question = {
  key: number;
  questionType: QuestionType;
  items: Item[];
};

type Questionnaire = {
  key: number;
  Questions: Question[];
};

위 코드는 QuestionnaireType을 정의하는 간단한 방법이다. 아마 많은 언어에서 같은 문제를 직면하면 위 코드처럼 작성할 것이다. 하지만 질문과 문항의 관계를 코드만 봐서는 알 수 없다.

Solution2

type ChoiceItemType = 'TEXT' | 'INPUT' | 'IMAGE';
type NarrativeItempType = 'INPUT';
type ImageItemType = 'IMAGE';

type ChoiceQuestionType = 'SINGLE_CHOICE' | 'MULTIPLE_CHOICE';
type NarrativeQuestionType = 'NARRATIVE';
type ImageQuestionType = 'IMAGE';

type CommonItem = {
  key: number;
};

type ChoiceItem = CommonItem & {
  itemType: ChoiceItemType;
};
type NarrativeItem = CommonItem & {
  itemType: NarrativeItempType;
};
type ImageItem = CommonItem & {
  itemType: ImageItemType;
};

type Question = {
  key: number;
} & (
  | {
      questionType: ChoiceQuestionType;
      items: ChoiceItem[];
    }
  | {
      questionType: NarrativeQuestionType;
      items: NarrativeItem[];
    }
  | {
      questionType: ImageQuestionType;
      items: ImageItem[];
    }
);

type Questionnaire = {
  key: number;
  questions: Question[];
};

union type을 사용해서 QuestionnaireType을 정의했다. 질문과 문항의 관계는 표현됐지만, 구현과 동시에 관계가 표현되어 있다. 더 복잡한 구조에선 직관적이지 못하다.

Solution3

type QuestionItemType = {
  SINGLE_CHOICE: 'TEXT' | 'INPUT' | 'IMAGE';
  MULTIPLE_CHOICE: 'TEXT' | 'INPUT' | 'IMAGE';
  NARRATIVE: 'INPUT';
  IMAGE: 'IMAGE';
};

// 'SINGLE_CHOICE' | 'MULTIPLE_CHOICE' | 'NARRATIVE' | 'IMAGE'
type QuestionType = keyof QuestionItemType;

type Item<T extends QuestionType> = {
  [K in T]: {
    key: number;
    itemType: QuestionItemType[K];
  };
};

type Question<T extends QuestionType> = {
  [K in T]: {
    key: number;
    questionType: K;
    items: Item<K>[];
  };
};

type Questionnaire = {
  key: number;
  Questions: Question<QuestionType>[];
};

질문과 문항의 관계를 QuestionItemType에 정의하여 구현과 관계의 표현을 구분했다. 질문과 문항의 관계가 형성되어 보다 엄밀한 QuestionnaireType이 되었다.

GroupByProperty - type manipulation

Definition

type FilterKeyByValueMap<T, V> = {
  [K in keyof T]: T[K] extends V ? K : never;
};

type FilterKeyByMap<T, K extends keyof T> = T[K] extends never ? never : T[K];

type GroupByProperty<
  T,
  P extends FilterKeyByMap<
    FilterKeyByValueMap<T[keyof T], keyof any>,
    keyof T[keyof T]
  >,
> = {
  [K in T[keyof T][P] as K extends keyof any ? K : never]: keyof {
    [K2 in keyof T as T[K2][P] extends K ? K2 : never]: undefined;
  };
};

GroupByProperty는 관계를 형성하기 위해 고안된 type이다. 이름에서도 알 수 있지만, 같은 속성끼리 묶어준다. 아래 예시를 보며 GroupByProperty의 역할을 설명하겠다.

Example

type FamilyType = 'Kim' | 'Park';

const peopleMap = {
  Juliet: { koreanName: '줄리엣', family: 'Kim', gender: 'F' },
  Romeo: { koreanName: '로미오', family: 'Park', gender: 'M' },
  Pat: { koreanName: '패트', family: 'Kim', gender: 'M' },
  Mat: { koreanName: '매트', family: 'Park', gender: 'M' },
} as const;

// 'Juliet' | 'Romeo' | 'Pat' | 'Mat'
type People = keyof typeof peopleMap;
// 'Juliet' | 'Pat'
type KimFamily = GroupByProperty<typeof peopleMap, 'family'>['Kim'];
// 'Romeo' | 'Mat'
type ParkFamily = GroupByProperty<typeof peopleMap, 'family'>['Park'];
// 'Juliet'
type FemalePeople = GroupByProperty<typeof peopleMap, 'gender'>['F'];
// 'Mat'
type MalePeople = GroupByProperty<typeof peopleMap, 'gender'>['M'];

const groupByFamily: {
  [K in FamilyType]: Array<
    GroupByProperty<typeof peopleMap, 'family'>[K]
  >;
} = {
  Kim: ['Juliet', 'Pat'],
  Park: ['Romeo', 'Mat'],
};

groupByFamily.Kim.forEach((value) => console.log(peopleMap[value].koreanName));

peopleMap 객체는 사람과 속성(국문명, 가문, 성별)의 관계를 표현하고 있다. GroupByProperty를 이용하면 peopleMap 객체의 사람들을 속성별로 나누어 type으로 관리할 수 있다. 어느 함수는 모든 사람을 받고 싶고 어느 함수는 김씨 가문만 받고 싶을 때, 매번 type을 정의하는 것이 아니라 관계를 표현하고 있는 peopleMap을 통해서 구현할 수 있다.

groupByFamily는 가문별 배열을 표현하고 있다. 이때, 타입스크립트로 실제로 해당 가문에 속하는 사람이 잘 작성되었는지 확인하기 위해 GroupByProperty를 사용했다.

또한 peopleMap 객체는 type이 아닌 값이기 때문에 실제 코드에서 값으로 사용할 수 있다. 마지막 줄의 코드는 김씨 가문 사람들의 국문명을 console에 출력한다.

Cons of Enum

  1. 다른 Enum과 관계 형성이 어렵다.
  2. keyvalue가 다르면 불편한 상황이 발생할 수 있다.
enum FooBar {
  FOO = 'fff',
  BAR = 'bbb',
}

const translation: Record<FooBar, string> = {
  [FooBar.FOO]: '',
  [FooBar.BAR]: '',
  // fff: '푸',
  // bbb: '바',
};

console.log(translation[FooBar.FOO]);
console.log(translation.fff);

Enum을 쓰지 말자는 얘기는 아니다. 하지만 dot 연산자를 쓰지 못하거나, 매번 대괄호를 써야 하는 불편함 있는 것은 분명하다. 단순한 상황에선 Enum이 좋지만, 복잡한 관계를 구현해야 한다면 다른 방안을 고려해보자.

Theme & variant - declaration merging

declare module '@mui/material/styles' {
  export interface Theme {
    colors: typeof COLORS;
    styles: typeof STYLES;
  }
}

타입스크립트의 interface는 같은 이름으로 여러 번 정의될 수 있으며 각기 다른 파일에 정의되어도 괜찮다. 그리고 해당 interface는 각 정의들의 교집합이 된다. 이 점을 이용하여 package에 정의된 interface에 원하는 속성을 추가할 수 있다. <자동접수>에서는 emotionThememuivariant에 원하는 속성을 추가하여 사용하고 있다. 자세한 사용법은 mui 공식 문서에서 확인하기 바란다.

intersection type과 함께 declaration merging에서 주의할 점이 있다.

// intersection type
type A = { foo: number; };
type B = { foo: string; };
type Foo = A & B;

// declaration merging
interface Foo { foo: number; }
interface Foo { foo: string; }

위 예시의 type Foointerface Foo의 결과는 아래와 같다.

type Foo = { foo: never; };
interface Foo { foo: never; }

stringnumber의 교집합이 never이므로 위와 같은 결과가 된다. 이렇게 될 경우, 대부분 원하는 대로 작동하지 않을 것이다. intersection typedeclaration merging을 사용할 때는 같은 이름의 속성이 있는지 확인해주지 않고 각 정의가 다른 파일에 있다면 확인하기 어렵기 때문에 주의가 필요하다.

of & ofMap - autocompletition

meme-typescript-autoplugin.png

위 이미지는 타입스크립트는 자동완성 플러그인이라는 유명한 밈이다. 이번에는 자동완성을 활용하는 방법에 관해 얘기한다.

P.S. 개발환경에 따라 자동완성 여부는 달라질 수 있다. 본 내용은 타입스크립트 플레이그라운드에서 확인했다.

Definition

function of<T>(): (obj: T) => T;
function of<T>(obj: T): T;
function of<T1>(obj1?: T1) {
  return arguments.length !== 0 ? obj1 : <T2>(obj2: T2) => obj2;
}
const ofMap =
  <T>() =>
  <R extends Record<string, T>>(map: R): R =>
    map;

<자동접수>에서는 자동완성과 type 검사를 위해 ofofMap이라는 함수를 정의해서 사용한다. 둘의 공통점은 어떤 인자를 받아서 그대로 반환한다는 것이다. 즉, 이 함수들의 사용 유무는 실행 결과에 영향을 미치지 않는다. 아래 문제를 통해 역할을 알아보자.

of 함수

Problem

variant에 따라 CSSObject를 반환하라. 색상과 배경 색상은 variant와 무관하며, variant에 따라 폰트크기와 높이가 결정된다.

Solution1

const buttonStyle = (variant: 'large' | 'small'): CSSObject => ({
  color: 'black',
  backgroundColor: 'white',
  ...(variant === 'large' && {
    fontSize: '26px',
    height: '65px',
  }),
  ...(variant === 'small' && {
    fontSize: '16px',
    height: '34px',
  }),
});

여러 방법이 있겠지만, 위의 코드처럼 spreading을 이용하여 구현할 수 있다. 이때, 반환형이 CSSObject로 정해져 있기 때문에 많은 개발환경에서 색상과 배경 색상은 colorbackgroundColor로 자동 완성된다. 하지만 폰트크기와 높이를 반환하는 객체는 자동 완성되지 않을뿐더러 올바른 type인지 검사되지 않는다.

Solution2

const cssObject = of<CSSObject>();
const buttonStyle = (variant: 'large' | 'small'): CSSObject => ({
  color: 'black',
  backgroundColor: 'white',
  ...(variant === 'large' &&
    cssObject({
      fontSize: '26px',
      height: '65px',
    })),
  ...(variant === 'small' &&
    cssObject({
      fontSize: '16px',
      height: '34px',
    })),
});

Solution1과 같은 방법이지만 of함수로 cssObject함수를 정의하여 사용하고 있다. cssObjectCSSObject 객체를 인자로 받고 그대로 반환한다. 이 과정에서 인자의 type을 검사하고 자동완성을 지원하여 안전한 코드를 작성할 수 있다.

ofMap 함수

ofMap 함수는 of 함수처럼 전달받은 인자를 그대로 반환하지만, 반환 type에서 차이를 보인다. of 함수는 type도 똑같이 반환하지만, ofMap 함수는 입력된 타입을 extends하는 type을 반환한다. 즉, 전달받는 인자의 typeRecord<string, T>만 만족하면 되고 반환형은 이를 extends하는 type으로 많은 상황에서 literal type이 되어 강력한 자동완성 기능을 제공한다. 아래 문제를 보며 차이를 살펴보자.

Problem

검은색, 흰색, 회색의 색상 코드를 저장하는 객체를 구현하라. 객체 작성 시 valuetype 검사 여부와 객체 호출 시 key의 자동완성 여부를 확인하라.

Solution1

const COLORS = {
  BLACK: '#000000',
  WHITE: '#FFFFFF',
  GRAY: '#808080',
};

가장 간단한 구현일 것이다. 객체 호출 시 key의 자동완성이 확인됐지만, 객체 작성 시 valuetype은 검사되지 않는다.

Solution2

const COLORS: Record<string, Property.Color> = {
  BLACK: '#000000',
  WHITE: '#FFFFFF',
  GRAY: '#808080',
};

valuetype을 검사하기 위해 Record를 사용하여 type을 지정했다. 하지만 객체 호출 시 key가 자동 완성되지 않았다.

Solution3

const createColors = ofMap<Property.Color>();
const COLORS = createColors({
  BLACK: '#000000',
  WHITE: '#FFFFFF',
  GRAY: '#808080',
} as const);

ofMap 함수로 구현하면 valuetypeProperty.Color인지 검사되고, COLORS.BLACK, COLORS.WHITE, COLORS.GRAY 호출 시 모두 자동 완성된다.

ofMap 함수의 역할은 용례를 통해 알아봤다. 위에 나왔던 peopleMapofMap 함수로 안전하게 구현할 수 있다. 아래는 peopleMap을 더 안전하게 구현한 코드이다.

const peopleMap = ofMap<{
  family: FamilyType;
  gender: 'F' | 'M';
}>()({
  Juliet: { koreanName: '줄리엣', family: 'Kim', gender: 'F' },
  Romeo: { koreanName: '로미오', family: 'Park', gender: 'M' },
  Pat: { koreanName: '패트', family: 'Kim', gender: 'M' },
  Mat: { koreanName: '매트', family: 'Park', gender: 'M' },
} as const);

Summary of ofMap function

// support type checking when we define, but no autocomplete when we call.
const colorsWithRecord: Record<string, Property.Color> = { ... };

// support autocomplete when we call, but no type checking when we define.
const colorsWithAsConst = { ... } as const;

// support type checking and autocomplete
const colorsWithCreateColors = createColors({ ... } as const);

Extra

위의 예시를 보면 ofMap 함수의 인자에 as const가 붙어있는 것을 확인할 수 있다. 일반적일 때, as const가 없어도 valuetype 검사와 key의 자동완성에 이상 없다. as constvalueliteral type으로 쓰기 위해 사용한다.

const peopleMap = ofMap<{
  family: FamilyType;
  gender: 'F' | 'M';
}>()({
  Juliet: { koreanName: '줄리엣', family: 'Kim', gender: 'F' },
  Romeo: { koreanName: '로미오', family: 'Park', gender: 'M' },
  Pat: { koreanName: '패트', family: 'Kim', gender: 'M' },
  Mat: { koreanName: '매트', family: 'Park', gender: 'M' },
});

위와 같이 peopleMapas const 없이 정의할 경우, peopleMap.koreanNametypestring이다. 하지만 koreanName에 대해 GroupByProperty를 적용하고 싶다면 literal type으로 만들어야 하므로 상황에 따라 as const를 써야 한다.