TypeScript 알차게 활용하기

들어가며

최근 통합 테스트 과정을 거치면서 TypeScript를 잘 활용할수록 에러 발생률을 줄일 수 있음을 체감하였다. 그래서 TypeScript 활용 팁을 정리해보았는데, 이번 글에서는 Utility Types와 Enums의 활용 방법을 살펴보고자 한다.

Utility Type으로 간편하게 타입 정의하기

TypeScript는 다양한 Utility Type을 제공하고 있는데, 이를 사용하면 좀 더 간편하게 타입을 정의할 수 있다.

몇 가지 유용한 Utility Type을 추려서 정리하면 아래와 같다.

1) Partial

필수적이지 않은 property는 이름 뒤에 ?를 붙여서 optional한 속성값임을 표시할 수 있다.

그런데 모든 property가 optional한 경우 일일이 물음표를 붙여주는 것은 번거로운 작업이다.

이 때 Partial<Type>을 사용하면 모든 속성이 optional한 타입을 간편하게 만들 수 있다.

interface User {
	id: string,
	email: string,
	phone: string,
	age: number,
}

type UserVo = Partial<User>;

// 아래 코드는 컴파일 단계에서 에러 발생
// "Property 'phone' is missing in type '{id: string; email: string; age: number;}', but required in type 'User'
const user: User = {
	id: 'gildong.hong',
	email: 'gdhong@nextree.io',
	age: 20,
}

// 아래 코드는 에러가 발생하지 않음
const user: UserVo = {
	id: 'gildong.hong',
	email: 'gdhong@nextree.io',
	age: 20,
}

2) Omit<Type, Keys>

특정 타입에서 몇 가지 속성만 제거하여 새로운 타입을 정의하고 싶을 때에는 Omit<Type, Keys>를 사용하면 유용하다.

아래 코드로 예를 들어 살펴보자면 Student 타입은 Entity 타입을 상속한 Person 타입을 상속하고 있어 총 여덟 개의 속성을 가지고 있다.

그런데 Entity의 속성은 제외하고 PersonStudent의 속성만 가진 타입을 정의하고 싶으면 어떻게 해야 할까?

물론 PersonStudent을 합친 여섯 가지 속성을 나열하여 새로운 타입을 정의할 수도 있다.

하지만 PersonStudent의 속성이 늘어날수록 위 과정은 번거로워지고, 코드는 비대해진다.

이러한 경우 Omit<Student, keyof Entity>과 같이 타입을 정의하면 Entity의 속성을 제거한 타입을 훨씬 간단하고 간결하게 정의할 수 있다.

interface Entity {
    id: string,
    registeredTime: number,
    modifiedTime: number,
}

interface Person extends Entity {
    name: string,
    age: number,
    gender: string,
}

interface Student extends Person {
    className: string,
    grade: number,
}

// Person과 Student의 속성을 합쳐 새로운 타입을 정의하는 방법 => Omit을 사용하는 경우보다 코드 양이 많음
// interface StudentInfo {
//     name: string,
//     age: number,
//     gender: string,
//     className: string,
//     grade: number,
// }

type StudentInfo = Omit<Student, keyof Entity>;

const student: StudentInfo = {
    name: "Gildong Hong",
    age: 17,
    gender: "male",
    className: "A",
    grade: 3,
}

3) Record<Keys, Type>

TypeScript에서는 Record<Keys, Type>을 사용하여 Map 구조의 타입을 정의할 수 있다.

JavaScript의 index signature와 다른 점은 Key를 Union Type으로 정의할 수 있다는 것이다.

interface AddressBook {
    name: string,
    phone: string,
    email: string,
}

type EmployeeName = "Kim" | "Lee" | "Park";

type EmployeeRecord = Record<EmployeeName, AddressBook>;

회사에 Kim, Lee, Park, 이렇게 세 명의 직원이 있다고 가정해보자.

Index Signature의 경우 직원 Park의 값이 빠져도 별도로 경고문을 띄우지 않는다.

// Index Signature
type EmployeeIndex = {[name: string] : AddressBook};

const employeesIndex: EmployeeIndex = {
    "Kim": {name: "HJ Kim", phone: "010-0000-0000", email: "hjkim@nextree.io"},
    "Lee": {name: "SK Lee", phone: "010-1111-1111", email: "sklee@nextree.io"},
}

반면, Record 타입은 특정 키의 값이 누락되면 컴파일 에러가 나면서 아래와 같이 경고문을 띄워준다.

따라서 모든 Key에 대한 값이 필수적인 경우 Record<Keys, Type>을 사용하면 실수를 미연에 방지할 수 있다.

상황에 맞게 Enum 사용하기

enum은 서로 관련 있는 상수들을 모아 놓은 열거형 타입이다.

원래 JavaScript에는 enum 기능이 없지만, TypeScript에는 자체적으로 구현한 enum 기능이 있다.

이 기능을 사용하는 것이 좋은지 아닌지에 대해서는 개발자들 간의 의견차가 있는데,

TypeScript의 enum이 개발 편의성을 제공하는 동시에 몇 가지 제한점을 가지고 있기 때문이다.

이와 관련된 내용들을 살펴보자.

1) enum으로 의미 있는 값 매핑하기

enum은 각각의 key에 해당되는 value를 지정할 수 있어 편리하다.

국제전화의 경우 1, 82 이렇게 국가번호만 나열되어 있으면 어느 나라의 것인지 확인하기 어렵다.

따라서 아래 코드와 같이 각각의 국가번호에 국가명을 key로 붙여주면 해당되는 값을 찾기가 훨씬 편리하다.

enum PhoneCode {
  UnitedStates = "1",
  France = "33",
  Japan = "81",
  Korea = "82",
}

console.log(PhoneCode.Korea); // 82  

**2) enum으로 오타 방지하기**

enum 기능을 사용하면 허용 가능한 값을 제한할 수 있고, 자동 완성 / 일괄 수정 등 IDE에서 지원하는 다양한 기능을 사용할 수 있다.

enum을 미리 정의해두면 아래와 같이 코드를 자동 완성해주기 때문에 오타로 인한 에러를 방지할 수 있다.

3) enum으로 key, value 순회하기

enum의 key에 대한 value를 string으로 지정하면 key와 value를 순회하며 사용할 수 있어 편리하다.

enum PhoneCode {
  UnitedStates = "1",
  France = "33",
  Japan = "81",
  Korea = "82",
}

// "UnitedStates", "France", "Japan", "Korea"
Object.keys(PhoneCode).forEach((country) => {
  console.log(country);
});

// "1", "33", "81", "82"
Object.values(PhoneCode).forEach((phoneCode) => {
  console.log(phoneCode);
});

한편, key에 대한 value를 별도로 지정하지 않으면, 첫 번째 key의 값을 0으로 하여 차례대로 1, 2, 3…의 값이 할당된다.

이때 유의해야 할 점은 enum의 value를 number로 지정하면 순회가 정상적이지 않은 방식으로 작동한다는 것이다.

Object.keys()를 사용했을 때는 key 값만, Object.values()를 사용했을 때는 value 값만 나와야하지만 numeric enum에서는 key와 value가 섞여 나오는 문제가 있다.

enum PhoneCode {
  UnitedStates = 1,
  France = 33,
  Japan = 81,
  Korea = 82,
}

// ["1", "33", "81", "82", "UnitedStates", "France", "Japan", "Korea"]
console.log(Object.keys(PhoneCode));

// ["UnitedStates", "France", "Japan", "Korea", 1, 33, 81, 82]
console.log(Object.values(PhoneCode));

4) enum? union type? const enum?

웹 성능 최적화의 관점에서 enum보다는 union type이나 const enum을 사용하는 것이 권장되기도 한다.

enum은 JavaScript에는 없는 기능이기 때문에 JavaScript 코드로 컴파일 되면 아래와 같이 즉시 실행 함수(IIFE, Immediately Invoked Function Expression)를 포함한 형태가 된다.

var PhoneCode;
(function (PhoneCode) {
    PhoneCode["UnitedStates"] = "1";
    PhoneCode["France"] = "33";
    PhoneCode["Japan"] = "81";
    PhoneCode["Korea"] = "82";
})(PhoneCode || (PhoneCode = {}));

번들러는 번들링을 할 때 사용되지 않는 코드를 제거하는 tree-shaking 과정을 거치는데,

TypeScript의 enum은 즉시 실행 함수로 인해 실제로 사용되지 않음에도 불구하고 tree-shaking 과정에서 제거되지 않는 경우가 있다.

따라서 아래와 같이 tree-shaking이 가능한 union type이나 const enum을 사용하는 것이 권장되기도 한다.

하지만 const enum은 key와 value에 대한 순회가 불가능하고, union type은 상대적으로 코드 가독성이 떨어진다는 단점이 있다.

// Union Type
const PhoneCode = {
    UnitedStates : "1",
    France : "33",
    Japan : "81",
    Korea : "82",
} as const;

type PhoneCode = typeof PhoneCode[keyof typeof PhoneCode];

// const enum 
const enum PhoneCode {
    UnitedStates = "1",
    France = "33",
    Japan = "81",
    Korea = "82",
}

한편, TypeScript의 enum이 tree-shaking 측면에서 비효율적인지 여부는 번들러의 종류나 버전에 따라 달라진다.

rollup 3.9.1 버전에서 테스트해보면 import 했지만 실제로는 사용하지 않는 enum이 tree-shaking 과정에서 제거되지 않고 그대로 남아 있는 것을 확인할 수 있었다.

반면, webpack 5.75.0 버전과 vite 4.0.3 버전에서 테스트 해보았을 때에는 import했지만 사용하지 않는 enum이 tree-shaking 과정에서 잘 제거됨을 확인할 수 있었다.

따라서 TypeScript enum을 사용할지 여부는 번들러의 종류 및 버전을 고려하여 결정하는 것이 적합할 것이다.

Hong

참고자료

TypeScript enum을 사용하지 않는 게 좋은 이유를 Tree-shaking 관점에서 소개합니다.