목차
조건부 타입과 infer 완벽 이해하기
지난 편에서는 객체가 어떤 타입인지 실행 중에 구분하는 방법을 살펴봤습니다.
if ("permissions" in account) {
// Admin
}클래스 인스턴스라면 `instanceof`를 사용했고,
if (error instanceof Error) {
console.log(error.message);
}직접 타입 가드를 만들기도 했습니다.
function isUser(
value: unknown
): value is User {
// 실제 데이터 검사
}여기까지는 모두 실제 값을 검사하는 이야기였습니다.
그런데 TypeScript에는 조금 다른 종류의 조건문도 있습니다.
이번에는 JavaScript 코드가 아니라 타입 안에서 조건을 판단합니다.
예를 들어 이런 타입을 만들고 싶다고 해보겠습니다.
type Result<T> =
T extends string
? "문자열"
: "문자열 아님";그리고 사용합니다.
type A = Result<string>;결과는:
"문자열"입니다.
반면:
type B = Result<number>;결과는:
"문자열 아님"입니다.
처음 보면 이런 생각이 들 수 있습니다.
타입 안에 삼항 연산자가 왜 들어가 있지?
맞습니다.
느낌부터 거의 삼항 연산자입니다.
JavaScript에서는 값을 보고 결과를 결정합니다.
const result =
value > 10
? "크다"
: "작다";TypeScript의 조건부 타입에서는 타입 관계를 보고 새로운 타입을 결정합니다.
type Result<T> =
T extends string
? string
: number;이것을 조건부 타입(Conditional Type)이라고 합니다.
그리고 조금 뒤에는 이런 코드도 만나게 됩니다.
type Return<T> =
T extends (...args: any[]) => infer R
? R
: never;`infer`라는 처음 보는 녀석이 등장합니다.
하지만 겁먹을 필요는 없습니다.
`infer`는 한 문장으로 말하면:
“여기에 들어있는 타입이 뭔지 TypeScript가 알아서 뽑아줘.”
정도의 역할입니다.
이번 편에서는 조건부 타입을 무작정 외우기보다, 왜 필요한지부터 하나씩 따라가 보겠습니다.
1. 이번 시간에 배울 내용
이번 편에서는 다음 내용을 다룹니다.
- 조건부 타입이란 무엇인가
- `T extends U ? X : Y` 문법
- 조건부 타입의 `extends`는 어떤 의미인가
- 기본 타입을 조건에 따라 변환하는 방법
- 객체 구조를 조건으로 검사하는 방법
- 배열인지 아닌지 판별하는 타입
- `never`와 조건부 타입
- 조건부 타입과 유니언
- 분배 조건부 타입이란 무엇인가
- 분배를 막는 방법
- `Exclude<T, U>`의 원리
- `Extract<T, U>`의 원리
- `NonNullable<T>`의 원리
- `infer`란 무엇인가
- 함수 반환 타입 추출하기
- 함수 매개변수 타입 추출하기
- 배열 요소 타입 추출하기
- Promise 내부 타입 추출하기
- 중첩된 타입에서 필요한 부분 꺼내기
- `infer`를 언제 쓰고 언제 안 쓰는 것이 좋은가
- 조건부 타입을 너무 복잡하게 만들면 생기는 문제
오늘 핵심 문장은 이것입니다.
조건부 타입은 입력 타입에 따라 결과 타입을 선택하고, infer는 타입 구조 안에서 필요한 타입을 추출한다.
2. JavaScript의 조건문부터 떠올려보자
JavaScript에서는 값에 따라 다른 결과를 만들 수 있습니다.
function getMessage(
isSuccess: boolean
): string {
if (isSuccess) {
return "성공";
}
return "실패";
}삼항 연산자를 사용하면 더 짧게 쓸 수도 있습니다.
const message =
isSuccess
? "성공"
: "실패";기본 구조는 다음과 같습니다.
조건
? 참일 때 값
: 거짓일 때 값조건부 타입도 생김새는 거의 같습니다.
type Result<T> =
T extends string
? "문자열"
: "기타";차이가 있다면 검사 대상이 값이 아니라 타입이라는 점입니다.
3. 조건부 타입 기본 문법
조건부 타입의 기본 형태는 다음과 같습니다.
T extends U
? X
: Y말로 읽으면 이렇게 됩니다.
`T`가 `U`에 할당 가능한 타입이라면 `X`, 그렇지 않으면 `Y`.
예를 들어:
type IsString<T> =
T extends string
? true
: false;사용해 보겠습니다.
type A =
IsString<string>;결과:
truetype B =
IsString<number>;결과:
falsetype C =
IsString<"hello">;결과:
true`"hello"`는 `string`에 포함되는 타입이기 때문입니다.
4. 여기서 `extends`는 상속이 아니다
`extends`라는 단어를 보면 클래스 상속이 먼저 떠오를 수 있습니다.
class Admin
extends User {
}인터페이스에서도 봤습니다.
interface Admin
extends User {
permissions: string[];
}하지만 조건부 타입에서는 조금 다르게 읽는 편이 좋습니다.
T extends string이것은 대략 다음 질문입니다.
T를 string 타입으로 취급할 수 있는가?
또는:
T는 string 타입의 범위 안에 들어가는가?
예를 들어:
"hello" extends string은 참입니다.
하지만:
number extends string은 거짓입니다.
조건부 타입에서 `extends`를 볼 때는 타입 호환 여부 검사라고 생각하면 이해하기 편합니다.
5. 문자열이라면 문자열 배열로 만들기
조금 실제적인 타입을 만들어 보겠습니다.
type ToArray<T> =
T extends string
? string[]
: T[];문자열을 넣습니다.
type A =
ToArray<string>;결과:
string[]숫자를 넣습니다.
type B =
ToArray<number>;결과:
number[]물론 이 예제에서는 조건이 꼭 필요하지는 않습니다.
그냥:
type ToArray<T> = T[];라고 하면 되니까요.
조건부 타입은 조건에 따라 결과 구조가 정말 달라질 때 의미가 있습니다.
6. 문자열과 숫자에 다른 결과 타입 주기
type DisplayValue<T> =
T extends string
? {
type: "text";
value: string;
}
: {
type: "number";
value: number;
};문자열 타입을 넣습니다.
type TextDisplay =
DisplayValue<string>;결과:
{
type: "text";
value: string;
}숫자를 넣으면:
type NumberDisplay =
DisplayValue<number>;결과:
{
type: "number";
value: number;
}입력 타입에 따라 결과 객체 구조 자체가 바뀌었습니다.
7. 객체 구조도 조건으로 검사할 수 있다
조건부 타입은 기본 타입만 검사하는 것이 아닙니다.
객체 구조도 확인할 수 있습니다.
type HasId<T> =
T extends {
id: number;
}
? true
: false;사용합니다.
type A =
HasId<{
id: number;
name: string;
}>;결과:
true다음 타입은 어떨까요?
type B =
HasId<{
name: string;
}>;결과:
falseTypeScript가 구조를 보고 판단합니다.
8. 구조적 타이핑과 조건부 타입
TypeScript는 구조적 타입 시스템을 사용합니다.
다음 객체 타입을 보겠습니다.
type User = {
id: number;
name: string;
email: string;
};조건:
T extends {
id: number;
}`User`에는 `id: number`가 있습니다.
추가로 `name`, `email`이 있어도 상관없습니다.
따라서:
type Result =
HasId<User>;결과는:
true입니다.
조건부 타입에서도 TypeScript의 구조적 타입 시스템이 그대로 적용됩니다.
9. 배열인지 확인하는 타입 만들기
실무 예제에 조금 가까워져 보겠습니다.
어떤 타입이 배열인지 판별하는 타입을 만들 수 있습니다.
type IsArray<T> =
T extends any[]
? true
: false;숫자 배열:
type A =
IsArray<number[]>;결과:
true문자열:
type B =
IsArray<string>;결과:
false객체:
type C =
IsArray<{
id: number;
}>;결과:
false10. readonly 배열도 고려하려면?
다음 배열은 `as const`로 만들어졌습니다.
const themes = [
"light",
"dark",
] as const;타입은 읽기 전용 튜플에 가깝습니다.
readonly [
"light",
"dark"
]따라서 배열 타입 검사를 조금 더 넓게 작성할 수 있습니다.
type IsArray<T> =
T extends readonly unknown[]
? true
: false;이제 일반 배열과 readonly 배열 모두 처리할 수 있습니다.
11. 함수인지 판별하는 타입
함수 타입도 확인할 수 있습니다.
type IsFunction<T> =
T extends (
...args: any[]
) => any
? true
: false;type A =
IsFunction<
() => void
>;결과:
truetype B =
IsFunction<string>;결과:
false조건부 타입을 사용하면 타입의 형태에 따라 결과를 만들 수 있습니다.
12. 조건이 여러 개라면?
조건부 타입도 중첩할 수 있습니다.
type TypeName<T> =
T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: "unknown";사용합니다.
type A =
TypeName<string>;결과:
"string"type B =
TypeName<number>;결과:
"number"type C =
TypeName<boolean>;결과:
"boolean"하지만 이런 중첩이 너무 길어지면 금방 읽기 어려워집니다.
타입이라고 해서 들여쓰기 지옥이 면제되는 것은 아닙니다.
13. 조건부 타입에서 `never`
`never`는 이전에도 여러 번 등장했습니다.
유니언에서 제거되는 특성이 특히 중요합니다.
다음 타입을 만들어 보겠습니다.
type OnlyString<T> =
T extends string
? T
: never;문자열을 넣으면:
type A =
OnlyString<string>;결과:
string숫자를 넣으면:
type B =
OnlyString<number>;결과:
never처음에는 별로 쓸모없어 보입니다.
그런데 유니언 타입을 넣는 순간 재미있는 일이 생깁니다.
14. 유니언 타입을 넣어보자
type Value =
string
| number
| boolean;다음 타입을 적용합니다.
type Result =
OnlyString<Value>;어떤 결과가 나올까요?
string입니다.
왜 이렇게 될까요?
조건부 타입이 유니언의 각 구성원에 나누어 적용되기 때문입니다.
15. 분배 조건부 타입
다음 조건부 타입이 있습니다.
type OnlyString<T> =
T extends string
? T
: never;여기에:
string
| number
| boolean을 넣으면 TypeScript가 개념적으로 다음처럼 처리합니다.
OnlyString<string>
|
OnlyString<number>
|
OnlyString<boolean>각각 계산하면:
string
|
never
|
never유니언에서 `never`는 사라집니다.
따라서 최종 결과:
string이 됩니다.
이러한 동작을 분배 조건부 타입(Distributive Conditional Type)이라고 합니다.
16. 조금 더 천천히 따라가 보자
다음 타입을 사용하겠습니다.
type ToArray<T> =
T extends unknown
? T[]
: never;유니언을 넣습니다.
type Result =
ToArray<
string | number
>;TypeScript는 각각 분리합니다.
string
number그리고 각각 배열로 만듭니다.
string[]
number[]최종 결과:
string[]
| number[]입니다.
여기서 중요한 차이를 하나 보겠습니다.
17. `(string | number)[]`와 다르다
다음 두 타입은 의미가 다릅니다.
string[]
| number[]그리고:
(string | number)[]첫 번째 타입은:
문자열 배열
또는
숫자 배열입니다.
따라서 이런 배열은 어렵습니다.
[
"hello",
100
]반면:
(string | number)[]는 하나의 배열 안에 두 타입이 섞일 수 있습니다.
9편에서 유니언 배열을 배울 때 봤던 차이가 여기서 다시 등장합니다.
18. 분배를 원하지 않는다면?
유니언 타입 전체를 하나로 보고 조건을 검사하고 싶을 수도 있습니다.
이럴 때 튜플로 감싸는 방법을 사용할 수 있습니다.
type ToArrayNonDist<T> =
[T] extends [unknown]
? T[]
: never;이제:
type Result =
ToArrayNonDist<
string | number
>;결과는:
(string | number)[]입니다.
조건 양쪽을 대괄호로 감싸면서 분배를 막은 것입니다.
19. 언제 분배가 발생할까?
다음처럼 타입 매개변수 `T`를 직접 조건의 왼쪽에 사용하면 분배가 발생할 수 있습니다.
T extends U
? X
: Y특히 `T`에 유니언 타입이 들어왔을 때입니다.
반면:
[T] extends [U]
? X
: Y처럼 감싸면 유니언 전체를 하나로 검사할 수 있습니다.
초반에는 이 문법을 억지로 외울 필요는 없습니다.
유니언 타입을 넣었더니 결과가 예상보다 여러 갈래로 나뉜다면:
“아, 조건부 타입이 분배됐나?”
를 떠올릴 정도면 충분합니다.
20. 우리가 이미 사용했던 `Exclude`
17편에서 다음 유틸리티 타입을 배웠습니다.
type Status =
| "idle"
| "loading"
| "success"
| "error";type ActiveStatus =
Exclude<
Status,
"idle"
>;결과:
"loading"
| "success"
| "error"당시에는 유틸리티 타입의 사용법에 집중했습니다.
이제 내부 원리를 이해할 수 있습니다.
21. `Exclude<T, U>` 직접 만들어보기
개념적으로 다음과 같은 조건부 타입입니다.
type MyExclude<T, U> =
T extends U
? never
: T;다음 타입을 넣습니다.
MyExclude<
"idle"
| "loading"
| "success"
| "error",
"idle"
>분배됩니다.
"idle"
→ "idle"에 해당
→ never
"loading"
→ 해당하지 않음
→ "loading"
"success"
→ "success"
"error"
→ "error"최종 결과:
"loading"
| "success"
| "error"입니다.
유틸리티 타입의 뚜껑을 열어보니 조건부 타입이 들어 있었습니다.
22. `Extract<T, U>`도 이해할 수 있다
17편의 `Extract`입니다.
type FinalStatus =
Extract<
Status,
"success" | "error"
>;개념적으로:
type MyExtract<T, U> =
T extends U
? T
: never;입니다.
`Exclude`와 조건만 반대입니다.
Exclude
조건에 맞음
→ 제거
Extract
조건에 맞음
→ 유지23. `NonNullable<T>`도 결국 같은 이야기
다음 타입이 있습니다.
type UserName =
string
| null
| undefined;type SafeUserName =
NonNullable<UserName>;결과:
string개념적으로 보면:
type MyNonNullable<T> =
T extends
null | undefined
? never
: T;와 비슷하게 이해할 수 있습니다.
string
→ 유지
null
→ never
undefined
→ never결과:
string입니다.
17편에서 외워두었던 공구의 작동 원리가 하나씩 연결되기 시작합니다.
24. 이제 `infer`를 만나보자
조건부 타입을 조금 더 강력하게 만들어주는 기능이 있습니다.
바로:
infer입니다.
예제를 먼저 보겠습니다.
type GetArrayItem<T> =
T extends Array<infer Item>
? Item
: T;처음 보면 꽤 부담스럽습니다.
하지만 핵심은 이 부분입니다.
infer Item말로 바꾸면:
배열 안에 들어있는 타입이 무엇인지 추론해서 `Item`이라는 이름으로 잡아줘.
정도입니다.
25. 배열 요소 타입을 `infer`로 추출하기
다음 타입을 사용해 보겠습니다.
type GetArrayItem<T> =
T extends Array<infer Item>
? Item
: never;숫자 배열:
type A =
GetArrayItem<number[]>;TypeScript가 다음 구조를 비교합니다.
number[]
Array<infer Item>`Item` 자리에 들어가는 타입은:
number입니다.
따라서 결과:
number문자열 배열:
type B =
GetArrayItem<string[]>;결과:
string26. `infer`는 타입 변수 하나를 임시로 만드는 느낌이다
이 코드를 다시 보겠습니다.
T extends Array<infer Item>
? Item
: never;`infer Item`은 조건이 맞는 경우 TypeScript에게:
여기 들어가는 타입을 알아내서 잠깐 `Item`이라고 부르자.
라고 하는 것입니다.
JavaScript의 변수 선언과 완전히 같지는 않지만 처음 이해할 때는 이렇게 생각하면 편합니다.
배열 구조 발견
↓
안에 있는 타입 추론
↓
그 타입에 Item이라는 이름 붙임
↓
결과 타입에서 사용27. readonly 배열까지 처리하기
다음 값이 있습니다.
const themes = [
"light",
"dark",
] as const;타입은 readonly 배열입니다.
따라서 좀 더 범용적으로 만들려면:
type GetArrayItem<T> =
T extends readonly (
infer Item
)[]
? Item
: never;라고 작성할 수 있습니다.
type Theme =
GetArrayItem<
typeof themes
>;결과:
"light"
| "dark"28. 그런데 배열 요소 타입은 이미 다른 방법도 있었다
16편에서 다음 방법을 배웠습니다.
type Item =
Users[number];실제 배열에서도:
type Item =
typeof users[number];를 사용할 수 있었습니다.
그렇다면 `infer`가 꼭 필요한 것은 아닙니다.
이게 중요한 포인트입니다.
`infer`를 배웠다고 모든 타입을 infer로 바꿀 필요는 없습니다.
간단하게 표현할 수 있다면 간단한 방법이 더 좋습니다.
`infer`는 타입 구조 안에서 타입을 추출해야 할 때 특히 유용합니다.
29. 함수 반환 타입을 직접 추출해 보자
다음 함수 타입이 있습니다.
type CreateUser =
(
name: string
) => {
id: number;
name: string;
};반환 타입만 꺼내고 싶습니다.
조건부 타입과 `infer`를 사용할 수 있습니다.
type GetReturnType<T> =
T extends (
...args: any[]
) => infer Return
? Return
: never;사용합니다.
type User =
GetReturnType<
CreateUser
>;결과:
{
id: number;
name: string;
}입니다.
30. 함수 반환 타입 추출 과정
이 부분은 천천히 볼 필요가 있습니다.
T extends (
...args: any[]
) => infer Return`T`가 함수인지 확인합니다.
그리고 그 함수의 반환 타입 자리를:
infer Return으로 둡니다.
예를 들어:
(name: string) => User가 들어오면:
매개변수
→ 신경 쓰지 않음
반환 타입
→ User
infer Return
→ User를 잡아냄그래서:
Return을 결과 타입으로 사용할 수 있습니다.
31. `ReturnType<T>`의 원리가 보이기 시작한다
17편에서 배운:
ReturnType<T>이 바로 이런 원리를 사용합니다.
type Result =
ReturnType<
typeof createUser
>;당시에는:
함수 반환 타입을 뽑는 유틸리티 타입
이라고 배웠습니다.
이제는 내부에서 무엇을 하고 있는지 감을 잡을 수 있습니다.
함수 타입인가?
↓
반환 타입 위치를 infer로 추론
↓
결과 타입으로 반환32. 함수 첫 번째 매개변수 타입 꺼내기
반환값뿐 아니라 매개변수 타입도 추출할 수 있습니다.
type FirstParameter<T> =
T extends (
first: infer P,
...args: any[]
) => any
? P
: never;함수를 준비합니다.
type UpdateUser =
(
userId: number,
name: string
) => void;type UserId =
FirstParameter<
UpdateUser
>;결과:
number입니다.
첫 번째 매개변수 위치에 들어가는 타입을 추론했습니다.
33. 모든 매개변수를 튜플로 추출하기
조금 더 확장해 보겠습니다.
type GetParameters<T> =
T extends (
...args: infer P
) => any
? P
: never;함수:
type CreateUser =
(
id: number,
name: string,
active: boolean
) => void;적용:
type Params =
GetParameters<
CreateUser
>;결과:
[
id: number,
name: string,
active: boolean
]17편의 `Parameters<T>`가 떠오르기 시작합니다.
34. `Parameters<T>` 역시 같은 아이디어다
TypeScript에서 이미 제공합니다.
type Params =
Parameters<
typeof createUser
>;우리가 직접 비슷하게 만들면:
type MyParameters<T> =
T extends (
...args: infer P
) => any
? P
: never;조건부 타입과 `infer`가 만나면 기존 타입에서 필요한 부분을 뽑아내는 도구를 만들 수 있습니다.
35. Promise 안의 타입 꺼내기
이번에는 비동기 코드입니다.
type Result =
Promise<User>;`User`만 꺼내고 싶습니다.
type UnwrapPromise<T> =
T extends Promise<infer R>
? R
: T;사용합니다.
type A =
UnwrapPromise<
Promise<User>
>;결과:
User입니다.
일반 문자열이라면:
type B =
UnwrapPromise<string>;결과:
string로 그대로 반환하도록 만들었습니다.
36. Promise가 여러 겹이라면?
다음 타입을 보겠습니다.
Promise<
Promise<string>
>한 번만 풀면:
Promise<string>이 남습니다.
재귀적으로 만들 수도 있습니다.
type DeepAwaited<T> =
T extends Promise<infer R>
? DeepAwaited<R>
: T;이제:
type Result =
DeepAwaited<
Promise<
Promise<string>
>
>;결과는:
string입니다.
TypeScript가 택배 상자를 열었더니 안에 또 상자가 있어서 계속 뜯고 있습니다. 📦
37. 실제로는 `Awaited<T>`가 있다
물론 직접 만들 필요는 없습니다.
17편에서 이미 배웠습니다.
type Result =
Awaited<
Promise<string>
>;결과:
string입니다.
중요한 것은 `DeepAwaited`를 외우는 것이 아닙니다.
`Awaited<T>` 같은 유틸리티 타입이 어떤 철학으로 만들어졌는지 이해하는 것입니다.
38. 객체 속성 타입도 추론할 수 있다
다음 구조에서 `data` 타입만 뽑아보겠습니다.
type ApiResponse = {
success: true;
data: User[];
};조건부 타입을 만듭니다.
type GetData<T> =
T extends {
data: infer D;
}
? D
: never;적용:
type Data =
GetData<
ApiResponse
>;결과:
User[]입니다.
`data` 자리에 있는 타입을 `infer D`로 가져왔습니다.
39. 중첩 객체에서도 가능하다
다음 타입이 있습니다.
type Response = {
result: {
data: {
users: User[];
};
};
};사용자 배열 타입을 추출해 보겠습니다.
type ExtractUsers<T> =
T extends {
result: {
data: {
users: infer U;
};
};
}
? U
: never;사용합니다.
type Users =
ExtractUsers<Response>;결과:
User[]입니다.
가능하긴 하지만 여기서 한 가지 생각해봐야 합니다.
이 정도 구조라면:
type Users =
Response[
"result"
][
"data"
][
"users"
];가 더 읽기 쉬울 수도 있습니다.
40. `infer`를 쓸 수 있다고 꼭 써야 하는 것은 아니다
중요한 실무 기준입니다.
다음 둘을 비교해 보겠습니다.
인덱스드 액세스 타입
type Name =
User["name"];조건부 타입과 infer
type Name<T> =
T extends {
name: infer N;
}
? N
: never;단순히 `User.name` 타입이 필요한 상황이라면 첫 번째가 훨씬 읽기 쉽습니다.
`infer`는 주로:
타입 구조가 제네릭으로 들어오고
그 내부 타입을 자동으로 추출해야 할 때빛을 발합니다.
배운 문법을 보여주기 위해 코드를 복잡하게 만들 필요는 없습니다.
좋은 TypeScript 코드는 타입 퍼즐 대회 출품작이 아닙니다.
41. 조건부 타입을 제네릭과 함께 사용하는 이유
조건부 타입은 보통 제네릭과 함께 사용할 때 진가가 드러납니다.
type ApiResult<T> =
T extends Error
? {
success: false;
error: T;
}
: {
success: true;
data: T;
};일반 데이터:
type UserResult =
ApiResult<User>;결과:
{
success: true;
data: User;
}Error:
type ErrorResult =
ApiResult<Error>;결과:
{
success: false;
error: Error;
}입력 타입에 따라 결과 구조를 바꿀 수 있습니다.
42. 배열이면 요소 타입, 아니면 그대로 반환하기
실무에서 이해하기 쉬운 예제를 하나 만들어 보겠습니다.
type UnwrapArray<T> =
T extends readonly (
infer Item
)[]
? Item
: T;배열:
type A =
UnwrapArray<
string[]
>;결과:
string일반 문자열:
type B =
UnwrapArray<string>;결과:
string객체 배열:
type C =
UnwrapArray<User[]>;결과:
User43. 여러 조건을 조합할 수도 있다
Promise와 배열을 함께 처리해 보겠습니다.
type Unwrap<T> =
T extends Promise<infer P>
? P
: T extends readonly (
infer Item
)[]
? Item
: T;type A =
Unwrap<
Promise<User>
>;결과:
Usertype B =
Unwrap<
User[]
>;결과:
Usertype C =
Unwrap<string>;결과:
string하지만 조건이 많아지면 타입이 급격하게 읽기 어려워집니다.
44. 복잡한 조건부 타입은 이름을 나누자
다음처럼 한 줄에 모든 논리를 넣을 수는 있습니다.
type Result<T> =
T extends Promise<infer P>
? P extends readonly (
infer Item
)[]
? Item
: P
: T extends readonly (
infer Item
)[]
? Item
: T;작동 여부와 별개로 읽기가 쉽지 않습니다.
차라리 역할을 나눕니다.
type UnwrapPromise<T> =
T extends Promise<infer P>
? P
: T;type UnwrapArray<T> =
T extends readonly (
infer Item
)[]
? Item
: T;그리고 필요할 때 조합합니다.
type Result<T> =
UnwrapArray<
UnwrapPromise<T>
>;코드를 압축한다고 이해까지 압축되는 것은 아닙니다.
45. 조건부 타입으로 객체 키 선택하기
조금 발전된 예제를 살펴보겠습니다.
다음 타입이 있습니다.
interface User {
id: number;
name: string;
email: string;
age: number;
isActive: boolean;
}문자열 값만 가진 키를 찾고 싶다고 해보겠습니다.
원하는 결과:
"name" | "email"먼저 각 속성을 검사합니다.
type StringKeys<T> = {
[K in keyof T]:
T[K] extends string
? K
: never;
}[keyof T];처음 보면 난이도가 갑자기 올라갑니다.
하나씩 보겠습니다.
46. `StringKeys<T>`가 동작하는 과정
먼저:
[K in keyof T]모든 키를 순회합니다.
`User`라면:
id
name
email
age
isActive각 값 타입이 문자열인지 검사합니다.
T[K] extends string
? K
: never결과는 개념적으로:
{
id: never;
name: "name";
email: "email";
age: never;
isActive: never;
}마지막으로:
[keyof T]모든 값 타입을 꺼냅니다.
never
| "name"
| "email"
| never
| never`never`가 제거되면:
"name"
| "email"만 남습니다.
47. 지금까지 배운 문법이 한곳에 모였다
`StringKeys<T>`에는 지금까지 배운 내용이 거의 다 들어 있습니다.
type StringKeys<T> = {
[K in keyof T]:
T[K] extends string
? K
: never;
}[keyof T];사용된 개념을 살펴보면:
keyof
→ 객체 키 추출
매핑된 타입
→ 키 순회
T[K]
→ 속성 타입 추출
조건부 타입
→ string인지 확인
never
→ 조건에 맞지 않는 키 제거
[keyof T]
→ 결과 값들을 유니언으로 추출TypeScript 연재를 차근차근 이어온 이유가 이런 부분에서 드러납니다.
각 문법을 따로 외울 때는 조각처럼 보였는데 이제 조립되기 시작합니다.
48. 숫자 속성의 키만 가져오기
같은 원리입니다.
type NumberKeys<T> = {
[K in keyof T]:
T[K] extends number
? K
: never;
}[keyof T];`User`에 적용합니다.
type Result =
NumberKeys<User>;결과:
"id" | "age"입니다.
이런 타입은 테이블의 숫자 정렬 필드 등을 제한할 때 활용할 수 있습니다.
49. 정렬 가능한 숫자 필드 만들기
예를 들어 상품 타입이 있습니다.
interface Product {
id: number;
name: string;
price: number;
stock: number;
description: string;
}숫자 필드만 가져옵니다.
type NumberKeys<T> = {
[K in keyof T]:
T[K] extends number
? K
: never;
}[keyof T];type ProductNumberKey =
NumberKeys<Product>;결과:
"id"
| "price"
| "stock"함수에 사용할 수 있습니다.
function sortByNumber(
key: ProductNumberKey
): void {
// ...
}다음은 가능합니다.
sortByNumber("price");sortByNumber("stock");문자열 속성은 막힙니다.
sortByNumber("description");타입 시스템이 업무 규칙을 조금 더 정확하게 표현하게 됩니다.
50. 조건부 타입과 `any`
여기서 조심할 타입이 있습니다.
바로 `any`입니다.
type Result<T> =
T extends string
? "string"
: "other";type A =
Result<any>;`any`는 타입 시스템의 일반적인 규칙을 흐릴 수 있기 때문에 예상하기 어려운 결과를 만들 수 있습니다.
복잡한 타입 유틸리티를 만들 때도 가능하면 `any`보다 `unknown`을 사용할 수 있는지 먼저 살펴보는 편이 좋습니다.
예:
type IsArray<T> =
T extends readonly unknown[]
? true
: false;51. 조건부 타입과 `never`
`never` 자체를 조건부 타입에 전달했을 때도 초보자가 예상하지 못한 결과를 볼 수 있습니다.
type Check<T> =
T extends string
? true
: false;type Result =
Check<never>;분배 조건부 타입에서 `never`는 분배할 구성원 자체가 없기 때문에 결과도 `never`가 될 수 있습니다.
이 단계에서는 이 특성을 깊게 외울 필요는 없습니다.
다만 복잡한 조건부 타입에서 갑자기 `never`가 나타난다면 분배 과정을 확인해볼 필요가 있습니다.
52. 실습 1: API 응답 데이터 꺼내기
API 응답 타입이 있습니다.
type ApiResponse<T> = {
status: number;
data: T;
};`data` 타입만 추출하는 타입을 만들어 보겠습니다.
type ExtractApiData<T> =
T extends {
data: infer D;
}
? D
: never;사용자 응답:
type UserResponse =
ApiResponse<User>;type UserData =
ExtractApiData<
UserResponse
>;결과:
User입니다.
53. 실습 2: 배열 API 결과에서 요소 하나 꺼내기
type ApiResponse<T> = {
data: T;
};다음 타입이 있습니다.
type Response =
ApiResponse<
User[]
>;배열 요소 하나까지 꺼내보겠습니다.
type ExtractArrayData<T> =
T extends {
data: readonly (
infer Item
)[];
}
? Item
: never;type Result =
ExtractArrayData<Response>;결과:
User입니다.
54. 실습 3: 함수 반환 타입 추출하기
function createProduct(
name: string,
price: number
) {
return {
id: Date.now(),
name,
price,
createdAt:
new Date(),
};
}직접 타입을 만듭니다.
type FunctionResult<T> =
T extends (
...args: any[]
) => infer R
? R
: never;사용:
type Product =
FunctionResult<
typeof createProduct
>;이제 `Product` 타입을 따로 중복 작성할 필요가 없습니다.
실무에서는 물론:
ReturnType<
typeof createProduct
>를 사용하는 편이 더 간결합니다.
이번 실습의 목적은 `ReturnType`을 다시 만드는 데 있습니다.
55. 실습 4: Promise 결과 추출하기
async function fetchUser() {
return {
id: 1,
name: "김타입",
};
}함수 반환 타입:
type AsyncResult =
ReturnType<
typeof fetchUser
>;결과는:
Promise<{
id: number;
name: string;
}>내부 데이터 타입만 추출합니다.
type PromiseValue<T> =
T extends Promise<infer R>
? R
: T;type User =
PromiseValue<
AsyncResult
>;결과:
{
id: number;
name: string;
}실무에서는:
Awaited<
ReturnType<
typeof fetchUser
>
>를 사용할 수 있습니다.
56. 실습 5: 이벤트의 payload 타입 추출하기
이벤트 타입을 만들어 보겠습니다.
type UserCreatedEvent = {
type: "user-created";
payload: {
id: number;
name: string;
};
};type ProductCreatedEvent = {
type: "product-created";
payload: {
id: number;
price: number;
};
};공통적으로 `payload` 타입을 추출하고 싶습니다.
type EventPayload<T> =
T extends {
payload: infer P;
}
? P
: never;type UserPayload =
EventPayload<
UserCreatedEvent
>;결과:
{
id: number;
name: string;
}이런 패턴은 이벤트 기반 코드에서도 종종 만날 수 있습니다.
57. 실습 6: 성공 API 응답만 추출하기
판별 유니언과 조건부 타입을 연결해 보겠습니다.
type ApiResult<T> =
| {
status: "success";
data: T;
}
| {
status: "error";
message: string;
};성공 타입만 남기는 타입을 만들어 보겠습니다.
type SuccessResult<T> =
T extends {
status: "success";
}
? T
: never;type UserResult =
ApiResult<User>;type Success =
SuccessResult<
UserResult
>;분배 조건부 타입이 적용됩니다.
결과:
{
status: "success";
data: User;
}입니다.
19편의 판별 유니언과 이번 조건부 타입이 연결됐습니다.
58. 타입을 직접 만들기 전에 내장 유틸리티부터 확인하자
조건부 타입과 `infer`를 배우고 나면 직접 모든 것을 만들고 싶어질 수 있습니다.
예를 들어:
type MyReturnType<T> =
T extends (
...args: any[]
) => infer R
? R
: never;재미있고 공부도 됩니다.
하지만 실제 프로젝트에서는 이미 있는 타입을 우선 사용하는 편이 좋습니다.
ReturnType<T>마찬가지입니다.
Exclude<T, U>
Extract<T, U>
NonNullable<T>
Parameters<T>
Awaited<T>이미 TypeScript가 제공하는데 같은 기능을 다시 만들 필요는 없습니다.
직접 구현해보는 것은 원리를 배우기 위해서입니다.
제품 코드에서는 검증된 공구가 있다면 그 공구부터 찾는 것이 좋습니다.
59. 조건부 타입이 필요한지 먼저 생각하자
다음 타입을 보겠습니다.
interface User {
id: number;
name: string;
}이름 타입이 필요합니다.
굳이:
type GetName<T> =
T extends {
name: infer N;
}
? N
: never;를 만들 필요는 없습니다.
그냥:
type UserName =
User["name"];이면 됩니다.
또한 속성 일부만 선택하고 싶다면:
Pick<User, "id">가 있습니다.
조건부 타입은 입력 타입의 조건에 따라 실제 결과가 달라져야 할 때 사용하는 것이 좋습니다.
60. 지나친 타입 체조를 피하자
다음과 같은 타입을 만났다고 가정하겠습니다.
type Result<T> =
T extends Promise<infer A>
? A extends readonly (
infer B
)[]
? B extends {
data: infer C;
}
? C extends string
? Uppercase<C>
: never
: never
: never
: never;작성자는 흐뭇할지 모르겠습니다.
그런데 3개월 뒤 유지보수하는 사람은 모니터 앞에서 잠시 말을 잃을 가능성이 높습니다.
타입도 코드입니다.
다음 기준을 생각해보는 것이 좋습니다.
읽자마자 의도를 알 수 있는가?
타입 이름만으로 역할을 알 수 있는가?
작은 타입으로 나눌 수 없는가?
내장 Utility Type으로 해결할 수 없는가?타입 시스템의 능력을 전부 보여주는 것과 유지보수하기 좋은 코드를 작성하는 것은 다른 문제입니다.
61. 자주 하는 실수 1: `extends`를 클래스 상속으로만 이해하기
T extends string조건부 타입에서는:
T가 string에 할당 가능한가?
라는 조건 검사에 가깝습니다.
클래스 상속 문법과 같은 단어를 사용하지만 문맥이 다릅니다.
62. 자주 하는 실수 2: 조건부 타입을 런타임 조건문으로 생각하기
다음 타입은:
type IsString<T> =
T extends string
? true
: false;실행 중에 동작하지 않습니다.
JavaScript 코드로 남는 조건문도 아닙니다.
TypeScript 타입 검사 단계에서만 사용됩니다.
Conditional Type
→ 컴파일 단계 타입 계산
if 문
→ 런타임 값 분기둘을 구분해야 합니다.
63. 자주 하는 실수 3: infer가 실제 값을 가져온다고 생각하기
infer R은 실제 함수 반환값을 실행해서 가져오는 기능이 아닙니다.
함수 타입 구조 안의 반환 타입을 추론합니다.
() => User에서:
User라는 타입을 추출하는 것입니다.
64. 자주 하는 실수 4: 유니언 분배를 모르고 결과를 예상하기
type ToArray<T> =
T extends unknown
? T[]
: never;type Result =
ToArray<
string | number
>;결과는:
string[]
| number[]입니다.
다음을 기대했다면:
(string | number)[]분배 조건부 타입을 확인해야 합니다.
65. 자주 하는 실수 5: `never`가 남아 있다고 생각하기
string
| never는 사실상:
string입니다.
그래서 조건부 타입에서 유니언 구성원을 제거할 때 `never`를 활용할 수 있습니다.
66. 자주 하는 실수 6: 모든 타입 추출에 infer 사용하기
type UserName =
User["name"];처럼 간단한 방법이 있다면 굳이 복잡하게 만들지 않는 편이 좋습니다.
`infer`가 필요한 상황과 단순 인덱스 접근으로 해결되는 상황을 구분합니다.
67. 조건부 타입 선택 가이드
조건부 타입이 필요한지 고민될 때 다음 질문을 해보면 됩니다.
입력 타입에 따라 결과 타입이 달라지는가?
그렇다면 조건부 타입을 고려합니다.
T extends U
? X
: Y유니언에서 특정 타입을 제거하려는가?
직접 만들기 전에:
Exclude<T, U>를 확인합니다.
유니언에서 특정 타입만 남기려는가?
Extract<T, U>를 확인합니다.
null과 undefined를 제거하려는가?
NonNullable<T>를 사용합니다.
함수 반환 타입을 가져오려는가?
ReturnType<T>를 사용합니다.
함수 매개변수 타입을 가져오려는가?
Parameters<T>를 확인합니다.
Promise 내부 타입을 가져오려는가?
Awaited<T>를 확인합니다.
제네릭 타입 구조 안에서 특정 타입을 꺼내야 하는가?
이때 `infer`를 고려해볼 수 있습니다.
68. 미니 퀴즈
문제 1
다음 타입의 결과는 무엇일까요?
type IsString<T> =
T extends string
? true
: false;
type Result =
IsString<string>;정답
true문제 2
다음 결과는 무엇일까요?
type Result =
IsString<number>;정답
false문제 3
다음 타입의 결과는 무엇일까요?
type OnlyString<T> =
T extends string
? T
: never;
type Result =
OnlyString<
string
| number
>;정답
string분배 조건부 타입이 적용되고 `number`는 `never`가 되어 제거됩니다.
문제 4
다음 결과는 무엇일까요?
type ToArray<T> =
T extends unknown
? T[]
: never;
type Result =
ToArray<
string | number
>;정답
string[]
| number[]문제 5
분배를 막고 다음 결과를 만들려면 어떻게 할까요?
(string | number)[]정답 예시
type ToArray<T> =
[T] extends [unknown]
? T[]
: never;문제 6
다음 `infer Item`은 무엇을 의미할까요?
T extends Array<infer Item>정답
`T`가 배열이라면 배열 요소 타입을 추론해 `Item`이라는 타입 이름으로 사용한다는 뜻입니다.
문제 7
다음 결과는 무엇일까요?
type Item<T> =
T extends (
infer U
)[]
? U
: never;
type Result =
Item<number[]>;정답
number문제 8
함수 반환 타입을 추출하는 내장 유틸리티 타입은?
정답
ReturnType<T>문제 9
Promise 내부 결과 타입을 추출할 때 사용할 수 있는 내장 타입은?
정답
Awaited<T>문제 10
다음 타입의 목적은 무엇일까요?
type StringKeys<T> = {
[K in keyof T]:
T[K] extends string
? K
: never;
}[keyof T];정답
객체 `T`에서 값 타입이 `string`인 속성의 키만 추출합니다.
69. 핵심 정리
조건부 타입의 기본 구조는 다음과 같습니다.
T extends U
? X
: Y`T`가 `U`와 호환되면 `X`, 아니면 `Y`를 선택합니다.
객체 구조도 검사할 수 있습니다.
type HasId<T> =
T extends {
id: number;
}
? true
: false;유니언 타입에 적용하면 각 구성원에 분배될 수 있습니다.
type OnlyString<T> =
T extends string
? T
: never;OnlyString<
string | number
>결과:
string분배를 막으려면 타입을 감쌀 수 있습니다.
[T] extends [U]
? X
: Y`infer`는 타입 구조 안에서 특정 타입을 추론합니다.
type ArrayItem<T> =
T extends (
infer Item
)[]
? Item
: never;함수 반환 타입도 추출할 수 있습니다.
type FunctionResult<T> =
T extends (
...args: any[]
) => infer R
? R
: never;Promise 내부 타입도 추출할 수 있습니다.
type PromiseValue<T> =
T extends Promise<infer R>
? R
: T;70. 유틸리티 타입과 다시 연결해보기
이번 편에서 조건부 타입과 `infer`를 배우고 나면 이전에 봤던 다음 타입들이 조금 다르게 보입니다.
Exclude<T, U>Extract<T, U>NonNullable<T>ReturnType<T>Parameters<T>Awaited<T>전에는:
TypeScript가 제공하는 편리한 기능
정도로 사용했습니다.
이제는 대략 어떤 방식으로 구현될 수 있는지 이해할 수 있습니다.
조건 판단
→ Conditional Type
유니언 구성원 제거
→ never
타입 내부 정보 추출
→ infer
객체 키 반복
→ Mapped Type
속성 타입 조회
→ Indexed Access TypeTypeScript의 여러 고급 문법이 사실 서로 따로 노는 기능이 아니라는 뜻입니다.
조금씩 연결되어 있습니다.
71. 마무리
처음 TypeScript를 배울 때 타입은 상당히 단순해 보입니다.
string
number
boolean조금 지나면 여러 타입을 합칩니다.
string | number객체의 구조를 만듭니다.
interface User {
id: number;
name: string;
}키를 추출합니다.
keyof User값 타입을 꺼냅니다.
User["name"]키를 반복하며 새로운 타입을 만듭니다.
{
[K in keyof User]:
User[K]
}그리고 이번에는 타입에게 조건까지 걸었습니다.
T extends U
? X
: Y필요하다면 내부에 숨어 있는 타입을 추론합니다.
infer R이제 타입 시스템 안에서도 상당히 많은 작업이 가능해졌습니다.
하지만 여기서 중요한 것은 “얼마나 복잡한 타입을 만들 수 있는가”가 아닙니다.
실무에서는 오히려 반대 질문이 더 중요합니다.
이 타입을 6개월 뒤 다른 개발자가 읽어도 이해할 수 있을까?
조건부 타입과 `infer`는 강력합니다.
그만큼 쉽게 과해질 수도 있습니다.
단순한 문제는 단순하게 해결하고,
User["name"]기존 유틸리티 타입이 있다면 그것을 사용하고,
ReturnType<T>정말 입력 타입에 따라 새로운 타입 규칙이 필요할 때 조건부 타입을 사용하는 것이 좋습니다.
이번 편의 핵심은 이 문장으로 정리할 수 있습니다.
조건부 타입은 타입에 조건을 부여하고, infer는 그 조건에 맞는 구조 안에서 필요한 타입을 꺼내온다.
TypeScript 타입 시스템이 이제 제법 프로그램처럼 보이기 시작합니다.
`if`도 있고, 반복도 있고, 값 대신 타입을 꺼내는 변수 비슷한 것도 있습니다.
여기까지 오면 슬슬 TypeScript가 JavaScript 옆에서 조용히 타입만 체크하는 친구가 아니라는 사실을 실감하게 됩니다.
가끔은 뒤에서 혼자 꽤 복잡한 계산을 하고 있었습니다. ⚙️
72. 다음 편 예고
이번 편에서는 조건에 따라 타입을 선택했습니다.
type IsString<T> =
T extends string
? true
: false;그리고 타입 구조 안에서 새로운 타입을 추론했습니다.
infer R그런데 지금까지 만든 타입 중에는 문자열을 조립하는 코드도 있었습니다.
예를 들어 16편에서 이런 타입을 잠깐 봤습니다.
type Getters<T> = {
[
K in keyof T
as `get${Capitalize<
string & K
>}`
]:
() => T[K];
};`name`이라는 키가:
getName으로 바뀌었습니다.
문자열 값이 아니라 문자열 타입 자체를 조립한 것입니다.
이 기능을 조금 더 제대로 알아두면 다음과 같은 타입을 만들 수 있습니다.
type EventName =
`${"user" | "product"}:${
"created"
| "updated"
| "deleted"
}`;결과는:
"user:created"
| "user:updated"
| "user:deleted"
| "product:created"
| "product:updated"
| "product:deleted"처럼 만들어집니다.
객체의 속성 이름을 이용해 이벤트 이름을 자동 생성할 수도 있습니다.
"nameChanged"
"emailChanged"
"ageChanged"HTTP API 경로나 CSS 속성 같은 일정한 문자열 규칙도 타입으로 표현할 수 있습니다.
다음 편에서는 TypeScript가 문자열까지 조립하기 시작합니다.
다음 이야기
[TypeScript 완전정복 #21] 문자열도 타입으로 조립할 수 있다
템플릿 리터럴 타입과 문자열 타입 변환 완벽 이해하기
다음 편에서는 이런 내용을 다룹니다.
- 템플릿 리터럴 타입이란 무엇인가
- JavaScript 템플릿 문자열과 무엇이 다른가
- 문자열 리터럴 유니언 조합하기
- 여러 유니언을 조합하면 어떻게 되는가
- `Uppercase<T>`
- `Lowercase<T>`
- `Capitalize<T>`
- `Uncapitalize<T>`
- 객체 키에서 이벤트 이름 만들기
- `getName`, `setName` 같은 메서드 이름 자동 생성
- 키 재매핑과 템플릿 리터럴 타입
- 일정한 문자열 규칙을 타입으로 제한하는 방법
PART 4의 마지막 편에서는 TypeScript가 문자열 레고 블록을 꺼냅니다.
`"user"`와 `"created"`를 끼웠더니 `"user:created"`라는 타입이 만들어지는, 처음 보면 조금 묘하지만 익숙해지면 꽤 쓸모 있는 기능입니다.
