[TS] string 타입 보다 더 구체적인 타입 사용하기

이주영·2024년 3월 9일
0

typescript

목록 보기
2/4
post-thumbnail

카탈로그🔖 : 타입 설계 > string 타입보다 더 구체적인 타입 사용하기

서론

이번 프로젝트를 하면서 동료 프론트엔드 개발자분이 타입을 지정하는 습관을 보며 많이 배웠다. 웬만해서는 일반적인 string을 사용하지 않고 적절한 타입이 무엇인지 고민하며 타입을 좁혀 설정하는 것을 코드 리뷰를 통해 보게 됐다. 그래서 이 부분도 정확히 정리하려고 한다.

본론

string의 타입의 범위는 어마어마하게 넓다. 한글자서부터 소설 전체 내용도 포함된다. string 타입으로 변수를 선언할 때 그보다는 좁은 타입이 적절할 수 있다고 생각한다. 


interface Album {
	artist : string;
	title : string;
	releaseDate : string; //YYYY-MM-DD
	recordingType : string; //'studio' 혹은 'live' 
}

위의 타입은 모두 string인데 나의 이전 프로젝트에서 주로 이런 식으로 타입을 선언하고 만족하고 있었다. 하지만 정확한 인터페이스는 아니었다는 것을 알게 됐다. 그 이유는 의도하지 않는 값이 들어갈 때 타입 체크를 해주지 않은 가능성이 높은 인터페이스이기 때문이다. 


const kindOfBlue : Album = {
	aritist : 'JuYoung',
	title : "singSangSong",
	releaseDate : 'August 17th', // 날짜 형식이 다름에도 오류를 알려주지 않는다.
	recordingType : 'Studio' // 'studio' 혹은 'live' 여야 하는데 'Studio'로 오타가 있지만... 타입 에러를 띄어주지 않는다.
} // 정상 작동

왜냐하면 interface에는 모두 string으로 지정해 놨기에 보다 정확하게 타입 시스템의 도움을 받지 못하는 것을 알 수 있다. 그렇다면 어떻게 하는 게 좋을까?! 

타입을 좁힌다?! 

타입을 좁힌다라고 생각하면 현재는 typeof 키워드를 활용해서 string일 경우는 무슨 타입 아닐 경우는 이런 타입을 지정해 줘 타입 가드를 활용하는 것으로 이해가 되는데 이 부분에서 말하는 타입을 좁혀서 무작정 사용한 string을 어떻게 보다 정확한 타입으로 만들 수 있는지 살펴보자.


type RecordingType = 'studio' | 'live'

interface Album {
	artist : string;
	title : string;
	releaseDate : Date; // Date 객체로 타입을 지정한다.
	recordingType : RecordingType; //'studio' 혹은 'live'만 허용하고 싶으니 유니온 타입을 할당한다. 
}

확실히 위의 타입으로 선언하면 이전보다 세밀하게 타입을 체크할 수 있으니 동료 개발자가 해당 코드를 이해하는데 보다 쉬을 것 같다. 이런 방식은 세 가지 장점이 있는데 

  1. 타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지된다는 것. 
    예시) 위에서 선언한 타입을 가지는 객체의 프로퍼티 중 하나를 활용하여 앨범을 찾는 함수를 작성한다면
function getAlbumsOfType(recordingType : string): Album[] {
// ...
}

위에서 recordingType 매개변수는 string 타입을 가진다고 명시했다. 하지만 유니온 타입으로 'studio'와 'live' 리터럴만 받도록 타입을 지정했으니 RecordingType으로 매개변수의 타입을 지정할 수 있다. 보다 정확해진다. 

function getAlbumsOfType(recordingType : RecordingType): Album[] {
// ...
}
  1. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능해지도록 할 수 있다. 
    예시) Underscore 라이브러리에 pluck 함수 구현체 살펴보기 
function pluck(records, key) {
	return records.map(r => r[key])
}

pluck의 함수 시그니처를 다음처럼 작성할 수 있다. 

function pluck(records: any[], key: string): any[] {
	return records.map(r => r[key]);
}

말 그대로 어떤 값이 들어올지 모르니 any를 사용한 것이지만 이럴 경우 타입 시스템에 구멍이 뚫리는 격이기에 절대 좋은 타입 설계는 아니다. 그렇다면 어떻게 할 것인가?! 

제네릭 타입을 활용하자!!


function pluck<T>(records: T[], key: string):any [] {
	return records.map(r => r[key]) // '{} 형식에 인덱스 시그니처가 없으므로 요소에 암시적으로 any 형식이 있습니다'라는 에러를 띄어준다. 
}

제네릭을 활용해서 타입을 지정해 주니!! key 타입에 대한 오류 메시지를 말해준다. key 타입의 범위가 너무 넓다는 것.  key에는 네 가지의 값만이 유효하다. 위에서 지정했던 타입을 살펴보면 'artist', 'title', 'releaseDate', 'recordingType'로 알 수 있다 이럴 경우 keyof를 사용하면 된다.


interface Album {
artist : string;
title : string;
releaseDate : Date; // Date 객체로 타입을 지정한다.
recordingType : RecordingType; //'studio' 혹은 'live'만 허용하고 싶으니 유니온 타입을 할당한다. 
}
type K = keyof Album;
// K의 타입은 유니온 타입으로 Album 객체의 키 값이 들어간다. ex)  'artist'|'title'|'releaseDate'|'recordingType'

위의 K 타입을 pluck 함수에 넣어주면 타입 체커를 통과하고 반환 타입을 추론해 준다. 


function pluck<T>(records: T[], key: keyof T) {
	return records.map(r => r[key]) 
}
//즉 정리해 보면 아래와 같은 타입이다
function pluck<T>(records: T[], key: keyof T): T[keyof T][]

만약 key의 값으로 string 값을 넣게 된다면 마찬가지로 범위가 또 넓어져버리는 문제가 발생합니다. 이해가 잘 안 되신다고요?! 예시를 살펴보죠.


type RecordingType = 'studio' | 'live'

interface Album {
	artist : string;
	title : string;
	releaseDate : Date; // Date 객체로 타입을 지정한다.
	recordingType : RecordingType; //'studio' 혹은 'live'만 허용하고 싶으니 유니온 타입을 할당한다. 
}

function pluck<T>(records: T[], key: keyof T) {
	return records.map(r => r[key]) 
}

const albums: Album[] = [{
	artist : 'JuYoung',
	title : "singSangSong",
	releaseDate : new Date(),
	recordingType : 'studio'
}, {
	artist : 'JuYoung',
	title : "singSangSong",
	releaseDate : new Date(),
	recordingType : 'studio'
}]

const artist = pluck(albums, 'releaseDate')  // ['JuYoung','JuYoung'] string[]
const releaseDates = pluck(albums, 'releaseDate')  // [ new Date{} , new Date{}] (string | Date)[]
const recordingType = pluck(albums, 'recordingType') // (string | Date)[]

pluck 함수에 의해 반환된 releaseDates를 보면 타입은 Date로 이루어진 배열이다. 하지만 타입을 보니 (string | Date)[];을 확인할 수 있었다. 제네릭의 두 번째 인자를 활용해서 이 문제를 해결할 수 있다. 


function pluck<T, K extends keyof T> (records : T[], key: K) : T[K][] {
	return records.map(r => r[key]);
}

const releaseDates = pluck(albums, 'releaseDate')  // Date[]
const recordingType = pluck(albums, 'recordingType') // RecordingType[]

이렇게 하면 타입 시그니처가 완벽하다. 위의 타입에서 바로 이해가 되지 않는 부분은 반환 값에 있는 배열 두 개인데 이 부분은 records 배열의 각 객체에서 key로 지정한 속성의 값들을 추출하여 배열로 만든다는 의미이다.

프로젝트 기반 코드 개념 이해하기

image

위에 있는 이미지를 타입을 선언해 보았다.


  

const CATEGORIES = ['축의금', '하객룩', '브라이덜샤워', '기타'] as const;

  

//변경 전

export type VoteType = {
  id: number;
  user: UserType;
  title: string;
  content: string;
  selections: SelectionType[];
  likes: number;
  views: number;
  voters: number;
  status: boolean;
  category: string; // 이 부분
  closeDate: string;
  createdAt: string;
  updatedAt: string;
  selected: null | number;
  isLiked: boolean;
}

//변경 후

export type VoteType = {
  id: number;
  user: UserType;
  title: string;
  content: string;
  selections: SelectionType[];
  likes: number;
  views: number;
  voters: number;
  status: boolean;
  category: (typeof CATEGORIES)[number];
  closeDate: string;
  createdAt: string;
  updatedAt: string;
  selected: null | number;
  isLiked: boolean;
};

날짜도 Date로 바꾸려 했지만 백엔드에서 'YYYY-MM-DD'로 내려주기에 바꾸긴 애매했지만 카테고리는 다섯 개로 정해져 있었다. 이 부분은 아무래도 기존 리터럴로 만들어 놓은 CATEGORIES 변수의 타입을 가지고 와서 number 형식의 인덱스가 들어갈 수 있다로 표현하였다.  기존 string 보다는 정확한 타입으로 변경해 보았다.

결론

  • string 보다는 더 구체적인 타입을 사용하자. 
  • 변수의 범위를 표현하고 싶다면 리터럴 타입의 유니온 ('a' | 'b' 혹은 keyof typeof SomeThing)을 사용하자.
  • 객체의 속성 이름을 함수 매개 변수로 받을 때는 string보다 keyof T를 사용하자.
  • 어떤 값이 들어올지 모를 때 any를 사용하지 말고 제네릭으로 타입을 선언하자! 
profile
https://danny-blog.vercel.app/ 문제 해결 과정을 정리하는 블로그입니다.

0개의 댓글