최근 TypeScript를 업무에서 항상 사용하고 있습니다.
기본적으로 React & Next.js와의 조합이 주요 활용 방법입니다.
그러나 본격적으로 이야기하기 전에, 평소 TypeScript를 어떻게 사용하고 있는지 소개할 필요가 있습니다.
제게 있어 TypeScript로 작성된 코드베이스가 건전하다는 것은 무엇을 의미할까요?
TypeScript가 부담을 늘리는 것이 아니라 도움이 되도록 설정해야 할 규칙은 무엇일까요?
이 글은 제 스스로의 생각과 해석을 섞은 글이 될 것입니다.
이 글을 읽으려면 TypeScript를 조금 다뤄본 경험이 필요할지도 모릅니다.
하지만 코드에서 컴파일러에 자주 막히거나 사용할 때마다 패배감을 느껴본 경험이 있는 분이라면, 이 글에서 분명 유익한 정보를 찾을 수 있을 거라 생각합니다.
먼저, TypeScript를 사용하는 이유로 돌아가 봅시다.
한 번 사용해보면 놓을 수 없는 두 가지 요소가 있습니다.
- 안전망: 변수나 함수를 올바른 방식으로 사용하지 않을 경우, 그 점을 지적해줍니다.
- 개발 경험과 자동 완성의 품질: 자신의 코드와 사용하는 라이브러리를 더 쉽게 파악할 수 있습니다.
그러나 다음과 같은 경우 TypeScript는 곧바로 고통스러운 것이 될 수 있습니다.
- 컴파일러에게 특정 타입을 무시하도록 하면 안전망에 큰 구멍이 생기는 경우.
- 타입을 여기저기서 다시 작성하여 변경 및 업데이트가 번거로워지는 경우.
이러한 상황을 방지하기 위해, 일상적으로 코딩할 때 제가 생각하는 3가지 황금 규칙을 소개하겠습니다.
- 필요한 경우에만 타입을 정의한다. (주로 함수 시그니처용)
any
와as
를 (거의) 사용하지 않는다.- 타입을 재정의하지 말고 파생을 우선한다.
물론 이러한 규칙에는 분명 예외도 있지만, 이를 염두에 두면 코드 품질을 향상시키고 특히 개발 경험(DX)을 개선할 수 있을 것입니다.
1. 필요한 경우에만 타입을 정의한다
// ❌ Bad
const values: string[] = ['의자', '테이블'];
// ✅ Good
const values = ['의자', '테이블'];
TypeScript에는 타입 추론 시스템이 있습니다.
이는 주어진 값에서 최대한 타입을 정의하려는 것입니다.
예를 들어 위의 코드에서는 할당된 값에서 직접 문자열 배열인 것을 알 수 있기 때문에 배열이 문자열 배열이라는 것을 명시할 필요가 없습니다.
이 기능을 활용하면 코드 작성이 훨씬 쉬워집니다.
예를 들어 함수의 결과를 변수에 할당하는 경우입니다.
const value = getValue();
getValue
의 반환 타입이 value
의 타입을 나타냅니다.
따라서 스스로 타입을 쓸 필요는 없습니다.
그러나 TypeScript가 대신 처리하지 못하는 부분도 있습니다.
그것은 함수 정의 부분입니다.
function sum(a, b) {
return a + b;
}
여기서 a
와 b
가 number
타입이라는 것을 TypeScript가 자동으로 알 수는 없습니다.
따라서 이를 명시해야 합니다.
function sum(a: number, b: number) {
return a + b;
}
이는 모든 함수 파라미터에 적용됩니다.
한편, 반환 값의 타입은 생략할 수 있습니다.
실제로 다음과 같이 작성할 필요는 없습니다.
function sum(a: number, b: number): number {
return a + b;
}
그러나 이는 타입 추론이 대신할 수 있는 경우에도 제가 타입을 추가하는 경향이 있는 예외입니다.
실제로 이 타입을 작성함으로써 코딩하기 전에 함수의 동작에 대해 생각하도록 강요받게 됩니다.
이는 타입이 안정적이지 않거나 한계 케이스에 특히 유용합니다.
예를 들어 hello('Park')
라고 호출하면 Hello Park!
라는 문자열을 반환해야 하는 hello
함수의 예를 생각해봅시다.
function hello(name: string) {
return `Hello ${name}!`;
}
만약 최종적으로 name
이 nullable(널 허용)이라면 어떻게 해야 할까요?
반환 값의 타입을 사전에 예측하지 않은 경우, 처음에는 다음과 같이 작성하려 할 것입니다.
function hello(name: string | null) {
// 빈 문자열의 경우도 동시에 처리할 수 있음
if (!name) {
return null;
}
return `Hello ${name}!`;
}
그러나 그 결과로 타입의 복잡성이 증가합니다.
왜냐하면 반환 값이 null 또는 string 중 하나가 되기 때문입니다.
이는 hello
함수를 사용하는 모든 곳에서 이 null을 계속 끌고 다니는 것을 의미합니다.
그리고 이 null은 아마 계속 퍼져 나가 모든 곳에서 !== null
조건을 강요할 것입니다.
반면에 반환 값의 타입을 function hello(name: string | null): string
으로 강제함으로써 TypeScript는 null을 반환하는 것이 함수 시그니처를 변경하고 복잡하게 만든다는 사실을 발견하는 데 도움이 되었을 것입니다.
따라서 이러한 null을 피하기 위해 저는 여기서 기본 문자열을 반환하는 것을 선호합니다.
function hello(name: string | null): string {
if (!name) {
return 'Hello world!';
}
return `Hello ${name}!`;
}
as const
TypeScript가 완전히 당신의 니즈를 예측하지 못하는 또 다른 상황은 정적 문자열의 경우입니다.
이는 종종 상수의 경우에 해당합니다.
const STATUS_LOADING = 'loading';
// 타입은 'loading'이며 string이 아님
const STATUS_COMPLETE = 'complete';
// 타입은 'complete'이며 string이 아님
const STATUS_ERROR = 'error';
// 타입은 'error'이며 string이 아님
여기서의 차이는 TypeScript가 이것이 임의의 문자열이 아니라 특정 문자열이라는 것을 이해할 수 있다는 점입니다.
이는 변수를 const
키워드로 선언했기 때문입니다.
그러나 다른 방법으로 작성했다면 TypeScript는 이를 문자열로 해석했을 것입니다.
const statuses = {
loading: 'loading',
complete: 'complete',
error: 'error',
};
// type {
// loading: string,
// complete: string,
// error: string,
// }
이제 statuses
에서 정의된 문자열 중 하나만을 값으로 갖는 변수를 만들고 싶다고 합시다.
그런 경우 다음과 같은 코드를 작성할 것입니다.
type Values<T extends Record<string, unknown>> = T[keyof T];
let status: Values<typeof statuses>;
이 코드가 이상해 보이더라도 당황할 필요는 없습니다.
타입 유도에 관한 세 번째 규칙에서 이에 대해 자세히 설명합니다.
그러나 이것은 let status: string
이라고 쓴 것과 같은 의미입니다.
따라서 status = "toto"
라고 해도 TypeScript는 아무런 문제도 찾지 못할 것입니다.
하지만 우리는 statuses
에서 사용할 수 있는 문자열 중 하나만 원합니다.
이 상황에서 매우 유용한 해결책은 값의 끝에 as const
를 추가하는 것입니다.
const statuses = {
loading: 'loading',
complete: 'complete',
error: 'error',
} as const;
이 작은 변경으로 다루는 타입이 훨씬 정확해집니다.
{
readonly loading: 'loading',
readonly complete: 'complete',
readonly error: 'error',
}
따라서 변수 status
의 타입은 더 이상 string이 아니라 'loading' | 'complete' | 'error'
가 되지만, 코드에는 거의 변경이 없습니다.
이는 코드 내 상수나 설정을 관리하는 데 특히 유용합니다.
수동으로 타입을 작성하는 것보다 이 방법을 적극적으로 사용하는 것을 추천합니다.
참고로 경우에 따라 enum을 사용하는 것만으로 충분할 때도 있습니다.
그러나 항상 실용적인 것은 아니며 코드를 변환해야 할 수도 있습니다.
반면 as const
는 모든 JavaScript 코드와 완벽하게 호환됩니다.
참고 자료: const 어설션 "as const"
그럼 두 번째 규칙으로 넘어가겠습니다.
2. any
를 절대 사용하지 않는다
any
는 TypeScript의 오픈 바(Open Bar, 제약이 없는 상태)입니다.
변수의 타입을 any
로 선언하면 무엇이든 할 수 있습니다.
숫자와 더하려고 하나요? 문제없습니다.
그 키를 가져오려고 하나요? 물론입니다.
그 키 안의 키를 가져오려고 하나요? 네. 실제 값이 undefined여도요.
이러한 이유로 최대한 피하는 것이 현명합니다.
그렇지 않으면 일반적인 JS를 사용하고 있는 것과 같습니다.
안전망도 없고 자동 완성도 없습니다.
그러나 때로는 어떤 타입을 사용해야 할지 정말 모를 때가 있습니다.
React의 Props 예시를 생각해봅시다.
프로퍼티의 값은 정말 무엇이든 될 수 있습니다.
숫자, 부울, 문자열, 객체, 함수 등입니다.
따라서 처음에는 다음과 같이 프로퍼티를 작성하려 할 것입니다.
type Props = Record<string, any>;
// 또는
type Props = { [key in string]: any };
그러나 이렇게 하면 프로퍼티를 어떻게든 조작할 수 있습니다.
props.quantity + props.className; // ok
반면에 any
를 unknown
으로 바꾸면 TypeScript는 조작하려는 것의 타입을 알 수 없다고 경고해줍니다.
props.quantity + props.className;
// props.quantity is of type unknown
// props.className is of type unknown
이것만으로도 좋은 첫 걸음입니다.
이제 받은 데이터의 형식이 분명하지 않다는 점을 인식해야 합니다.
자주 볼 수 있는 옵션 중 하나는 as
키워드를 사용하는 것입니다.
const quantity = props.quantity as number;
// quantity: number
그러나 이렇게 하면 TypeScript에게 "믿어줘"라고 말하는 것뿐입니다.
이것은 오픈 바이지만 의식적인 오픈 바입니다.
정말 실수할 가능성이 없다면 작업 속도를 올릴 수 있을지도 모릅니다.
그러나 여전히 오픈 바이기 때문에 보통은 추천하지 않습니다.
더 나은 방법은 JavaScript를 사용하여 데이터를 사용하기 전에 데이터의 타입을 확인하거나 강제하는 것입니다.
// 데이터를 변환하기
const quantity = Number(props.quantity);
// quantity: number
if (Number.isNaN(quantity)) {
// 타입은 맞지만 실제 숫자가 아닐 때 처리 필요
}
// 타입을 확인하기
const quantity = props.quantity;
if (typeof quantity === 'number') {
// ...
}
때로는 번거로운 작업이 될 수 있지만, 그런 상황에서 Zod나 Valibot 같은 라이브러리가 도움이 될 수 있습니다.
그러나 적어도 이를 통해 데이터의 품질을 보장할 수 있습니다.
이와 관련해 맷 포콕(Matt Pocock)의 글 "An unknown can't always fix an any"이 유용할 것입니다.
하지만 대부분의 경우 any
나 unknown
을 사용할 필요가 없습니다.
특히 상위 코드가 이미 quantity
타입을 검증하고 있을 가능성이 높습니다.
그런 경우 타입 파생을 사용하는 것이 더 나은 해결책이 될 것입니다.
예외는 항상 존재합니다.
예를 들어, 데이터가 두 줄 전에 정의되어 있고 코드 레벨에서 위험이 없지만 타입을 추가하는 것이 복잡한 경우에는 가장 간단한 방법을 선택해야 합니다.
다음에 코드를 볼 사람을 위해 주석을 추가하고 자동 테스트를 수행한 다음 다음 단계로 넘어가세요.
중요한 것은 교조적으로 굴지 않고 그로 인해 발생할 수 있는 위험을 이해하는 것입니다.
3. 타입의 파생을 우선한다
스칼라 타입(string, number, boolean 등)을 다룰 때 TypeScript의 타입 추론이 매우 잘 작동하기 때문에 일반적으로 타입을 반복해서 지정할 필요는 거의 없습니다.
그러나 객체를 다루기 시작하면 TypeScript는 반환 타입을 추론하기 어려워집니다.
예를 들어, children
키를 제외한 모든 객체를 얻고자 하는 함수를 생각해봅시다.
이는 예를 들어 React에서 props를 처리할 때 유용할 수 있습니다.
function getAttributes(props) {
const attributes = { ...props };
delete attributes['children'];
return attributes;
}
const props = {
className: 'button',
children: 'Send',
};
// type: { className: string, children: string }
const attributes = getAttributes(props);
// { className: 'button' }
TypeScript에서 getAttributes
함수를 최대한 직접적으로 작성하려면 다음과 같이 작성할 것입니다.
function getAttributes(props: Record<string, unknown>): Record<string, unknown>
확실히 이것은 작동합니다.
입력으로 키-값 객체를 받아 출력으로 새 키-값 객체를 반환합니다.
그러나 문제는 className
속성을 재사용하려고 할 때 TypeScript가 불만을 나타내는 것입니다.
attributes.className; // type `unknown`
TypeScript에게 className
의 타입을 어떻게 알려줄까요?
그러나 attributes.className as string
으로 처리하는 것은 허용되지 않습니다.
이는 TypeScript에 강제성을 부여하여 오류를 간과하게 만듭니다.
오히려 해결책은 함수의 타입 정의를 개선하는 데 있습니다.
현재 함수 시그니처에서 반환 타입을 새 레코드로 작성하고 있지만 실제로는 키를 제거한 동일한 Record
여야 합니다.
function getAttributes(props: Record<string, unknown>): Record<string, unknown>
// ^ 이 Record는 앞의 Record와는 다릅니다. ^
이를 TypeScript에 설명하려면 "제네릭"으로 처리해야 합니다.
생각하는 방식은 타입을 변수에 저장하는 것과 같습니다.
function getAttributes<Props extends Record<string, unknown>>(props: Props): Props
getAttributes<T extends ...>
형식에 익숙하고 이 Props
가 이상하게 느껴진다면 설명을 읽기 위해 여기를 클릭하세요.
위의 코드에서는 키워드 extends
를 사용하여 Props
라는 타입 변수에 Record<string, unknown>
의 제약을 갖는 타입을 저장하고 있습니다.
따라서 Props
는 반드시 키-값 객체가 됩니다.
이렇게 함으로써 TypeScript가 코드를 컴파일할 때
getAttributes
함수를props
파라미터와 함께 호출할 때 그 타입이{ className: string, children: string }
이라는 것을 인식합니다.- 이를 통해 자동으로
Props = { className: string, children: string }
으로 추론됩니다(이를 타입 추론이라고 합니다). - 따라서 함수의 반환 타입이
Props
이므로 동일한 객체 형식{ className: string, children: string }
을 정확히 재사용합니다.
이것은 이미 훌륭한 첫 걸음이지만 현시점에서는 attributes
가 항상 children
키를 가진 객체가 됩니다.
따라서 원래 객체에서 children
키를 제외하기 위해 반환 타입을 변환해야 합니다.
Omit<Props, 'children'>
function getAttributes<T extends Record<string, unknown>>(
props: T
): Omit<T, 'children'>
따라서 { className: string, children: ReactNode }
을 입력으로 전달했다면 getAttributes
함수의 반환 타입은 { className: string }
이 될 것입니다.
그리고 그 결과 함수의 결과를 조작할 때 정확히 올바른 타입을 얻을 수 있습니다.
const attributes = getAttributes(props);
attributes.className; // type string
TypeScript에는 Omit
과 같은 편리한 타입(유틸리티 타입)이 많이 있으며 때로는 매우 유용할 수 있습니다.
또한 모든 코드에 적용되는 것이지만 목표를 달성하는 방법은 종종 여러 가지가 있습니다.
따라서 문제를 다른 각도에서 보는 것이 도움이 될 수 있습니다.
처음 떠오르는 아이디어가 항상 최선은 아닙니다.
예를 들어, 다음과 같이 타입을 변경할 수도 있었을 것입니다.
function getAttributes<
Attributes extends Record<string, unknown>,
Props extends Attributes & { children: ReactNode }
>(props: Props): Attributes
Omit
을 사용하여 children
프로퍼티를 제외하는 대신 Props
가 여러 키(Attributes
)와 children
키로 구성되어 있다는 것을 처음부터 명확히 했습니다.
또한 타입 변수를 추가하여 제네릭으로 만들 수 있다고 말씀드렸는데, 실제로 원하는 만큼 많은 타입 변수를 추가하고 원하는 이름을 붙일 수 있습니다.
이를 위해 여기서는 Attributes
와 Props
를 정의했습니다.
참고 자료: 제네릭 (Generics)
범용 타입(제네릭)
마지막으로 타입 파생에 관해 이야기할 때 직접 함수에서 시작했습니다.
그러나 이 범용 타입의 개념은 단일 타입에도 적용할 수 있습니다.
거의 동일하게 작동합니다.
type Attributes<T extends Record<string, unknown>> = Omit<T, 'children'>;
이는 동일한 타입을 여러 곳에서 재사용할 수 있기 때문에 유용합니다.
따라서 저는 다음과 같이 함수를 다시 작성할 수 있었을 것입니다.
function getAttributes<T extends Record<string, unknown>>(
props: T
): Attributes<T>
코드가 일반적일수록 이에 의존할 필요가 있습니다
.
가능성은 정말 무궁무진하며, 타입 정의에 깊이 파고들 수 있습니다.
그럼에도 불구하고 여전히 어려울 수 있습니다.
이는 TypeScript의 가장 복잡한 부분이며 이해하는 데 시간이 걸립니다.
그러나 점차 특정 개념에 익숙해지고 다른 개념을 발견할 수 있게 될 것입니다.
요약
드디어 끝에 도달했습니다.
조금 힘들었지만 끝까지 해냈습니다.
TypeScript는 기본적인 부분이 제대로 타입 정의되어 있다면 실제로는 눈에 잘 띄지 않게 됩니다.
이는 많은 버그를 방지할 수 있을 뿐만 아니라 개발 경험도 크게 향상시킵니다.
이러한 이유로 다음 3가지 규칙을 따르며 건전한 기반에 투자할 가치가 있습니다.
- 필요한 타입만 정의한다.
any
나as
를 사용하지 않는다(as const
는 제외).- 타입을 재정의하지 말고 파생시킨다.
'Javascript' 카테고리의 다른 글
자바스크립트 배열 완전 정복: 희소 배열부터 다양한 메서드 활용까지 (0) | 2024.05.17 |
---|---|
TypeScript 고급 타입 마스터하기 - 객체 키 추출부터 infer 활용까지 (1) | 2024.05.15 |
TypeScript 제네릭 패턴과 활용법 - 클래스, 함수, 메서드 완벽 분석 (0) | 2024.05.15 |
TypeScript 4.5 이상 버전에서 추가된 TSConfig 옵션 살펴보기 (0) | 2024.05.12 |
TypeScript를 다른 도구로 다루기 위한 컴파일러 옵션 (0) | 2024.05.11 |