TypeScript Inference and Generic Components
Omit과 Exclude를 외워 쓰는 동안에는 왜 어떤 오타는 걸리고 어떤 오타는 지나가는지, 왜 React.FC를 붙이는 순간 제네릭이 사라지는지 설명할 수 없었습니다. 유틸리티 타입을 조건부 타입과 infer로 직접 다시 만들어 보면 추론이 어느 자리에서 유니언이 되고 어느 자리에서 끊기는지 규칙으로 드러납니다. 그 규칙을 기준으로 삼으면 컴포넌트 props를 FC로 감쌀지, 함수 선언으로 둘지, ref를 어디서 받을지가 취향이 아니라 추론 결과로 정해집니다.
조건부 타입은 유니언에 분배되고 infer는 참 분기에서만 보입니다
조건부 타입 T extends U ? X : Y에서 검사 대상 T가 벌거벗은 타입 매개변수이면 유니언이 들어왔을 때 멤버마다 따로 평가됩니다. TypeScript 2.8 릴리스 노트는 이를 분배 조건부 타입이라고 부르고 A | B | C가 (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)로 풀린다고 설명합니다. 분배를 막으려면 양쪽을 튜플로 감쌉니다. Distributive Conditional Types
type ToArray<T> = T extends any ? T[] : never
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type A = ToArray<string | number> // string[] | number[]
type B = ToArrayNonDist<string | number> // (string | number)[]infer는 extends 절 안에서 새 타입 변수를 선언하고 비교하면서 잡힌 타입을 거기에 담습니다. 선언된 변수는 참 분기에서만 참조할 수 있고 일반 타입 매개변수의 제약 절에는 쓸 수 없습니다. 한 타입에서 여러 모양을 차례로 벗기고 싶으면 조건부 타입을 이어 붙입니다. 릴리스 노트는 이를 순서대로 평가되는 패턴 매칭의 나열이라고 표현합니다. Type inference in conditional types
type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: never[]) => infer U ? U :
T extends Promise<infer U> ? U :
T
type A = Unpacked<string[]> // string
type B = Unpacked<() => string> // string
type C = Unpacked<Promise<string>[]> // Promise<string>, 배열만 벗깁니다
type D = Unpacked<Unpacked<Promise<string>[]>> // string
type Wrong1<T extends (infer U)[]> = T[0] // 제약 절에는 쓸 수 없습니다
type Wrong2<T> = T extends (infer U)[] ? T : U // 거짓 분기에서는 U를 찾지 못합니다함수 매개변수 자리를 never[]로 두는 이유는 매개변수가 반공변 자리라서입니다. 어떤 매개변수 목록이든 never를 받는 함수 타입에 할당할 수 있으므로 반환 타입만 보고 싶을 때 가장 넓은 패턴이 됩니다. infer를 둘 수 있는 자리는 아래와 같습니다.
| 자리 | 패턴 | 쓰임 |
|---|---|---|
| 배열 요소 | T extends (infer U)[] | 목록의 항목 타입 |
| 함수 반환 | T extends (...args: never[]) => infer R | ReturnType |
| 함수 매개변수 | T extends (...args: infer P) => unknown | Parameters |
| 튜플 첫 요소 | T extends [infer H, ...unknown[]] | 첫 인수만 제약 |
| Promise 결과 | T extends Promise<infer V> | Awaited의 한 단계 |
| 템플릿 리터럴 | T extends `on${infer E}` | 핸들러 이름에서 이벤트 이름 |
템플릿 리터럴 자리는 문자열 리터럴 유니언을 다룰 때 자주 씁니다. type EventName<T> = T extends `on${infer E}` ? Uncapitalize<E> : never에 'onClick' | 'onChange' | 'id'를 넣으면 분배 덕분에 'click' | 'change'가 나오고 패턴에 맞지 않는 'id'는 never로 사라집니다.
같은 이름의 infer가 여러 번 잡히면 자리가 결과를 정합니다
한 조건부 타입 안에서 infer U를 두 곳에 쓰면 후보가 둘 생깁니다. 릴리스 노트의 규칙은 분명합니다. 공변 자리에서 잡힌 후보가 있으면 유니언, 공변 후보는 없고 반공변 자리에서 잡힌 후보만 있으면 교집합, 둘 다 없으면 never입니다. 프로퍼티 값은 공변 자리이고 함수 매개변수는 반공변 자리입니다.
type Values<T> = T extends { a: infer U; b: infer U } ? U : never
type Args<T> = T extends { a: (x: infer U) => void; b: (x: infer U) => void } ? U : never
type V = Values<{ a: string; b: number }> // string | number
type P = Args<{ a: (x: string) => void; b: (x: number) => void }> // string & number, never로 줄어듭니다
type Q = Args<{ a: (x: { id: string }) => void; b: (x: { name: string }) => void }> // { id: string } & { name: string }string & number는 공존할 수 없는 원시 타입의 교집합이라 never로 줄어듭니다. 객체 타입끼리라면 두 모양을 모두 만족하는 타입이 남습니다. 이 규칙을 알면 자주 보이는 UnionToIntersection이 왜 동작하는지 두 줄로 설명할 수 있습니다. 첫 조건부 타입이 유니언을 멤버별 함수 타입의 유니언으로 바꾸고 두 번째 조건부 타입이 그 함수들의 매개변수 자리에서 infer I를 잡으므로 결과가 교집합이 됩니다.
type UnionToIntersection<U> =
(U extends unknown ? (arg: U) => void : never) extends (arg: infer I) => void ? I : never
type Merged = UnionToIntersection<{ a: 1 } | { b: 2 }> // { a: 1 } & { b: 2 }오버로드된 함수에서는 후보를 모으지 않습니다. 핸드북은 여러 호출 시그니처가 있는 타입에서는 마지막 시그니처로만 추론하고 인수 목록으로 오버로드를 고를 수는 없다고 밝힙니다. 그래서 ReturnType<typeof stringOrNum>은 마지막 시그니처의 string | number가 됩니다.
TypeScript 4.7부터는 잡은 변수에 바로 제약을 붙일 수 있습니다. 튜플의 첫 요소가 문자열일 때만 그 타입을 돌려주는 타입은 예전에 조건부 타입 두 개가 필요했지만 infer S extends string으로 한 번에 씁니다. 제약을 만족하지 않으면 거짓 분기로 갑니다. extends Constraints on infer Type Variables
type FirstIfString<T> = T extends [infer S extends string, ...unknown[]] ? S : never
type A = FirstIfString<['hello', number]> // 'hello'
type B = FirstIfString<[boolean, string]> // never같은 버전에서 타입 매개변수에 in, out으로 변성을 적는 문법도 생겼습니다. 출력 자리에만 쓰이면 out, 입력 자리에만 쓰이면 in, 둘 다면 불변입니다. 릴리스 노트는 모든 타입 매개변수에 붙이라고 권하지 않고 깊게 재귀하는 타입을 만드는 라이브러리 작성자에게 도움이 된다고 적습니다. 애플리케이션 코드에서는 위의 공변과 반공변 구분을 읽는 용도로만 알아 두면 충분합니다.
유틸리티 타입을 다시 만들면 키 집합과 유니언 멤버가 갈립니다
lib에 들어 있는 유틸리티 타입은 위 규칙의 조합입니다. 아래 정의는 TypeScript 7.0으로 컴파일해 내장 타입과 같은 결과를 내는지 확인했습니다. NonNullable은 4.8부터 T & {}로 정의돼 있고 Awaited의 실제 정의는 null과 undefined, 함수가 아닌 then까지 처리하므로 아래는 핵심 구조만 남긴 모양입니다.
type MyPartial<T> = { [K in keyof T]?: T[K] }
type MyRequired<T> = { [K in keyof T]-?: T[K] }
type MyPick<T, K extends keyof T> = { [P in K]: T[P] }
type MyRecord<K extends keyof any, T> = { [P in K]: T }
type MyExclude<T, U> = T extends U ? never : T
type MyExtract<T, U> = T extends U ? T : never
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>
type MyNonNullable<T> = T & {}
type MyReturnType<T extends (...args: never[]) => unknown> =
T extends (...args: never[]) => infer R ? R : never
type MyParameters<T extends (...args: never[]) => unknown> =
T extends (...args: infer P) => unknown ? P : never
type MyAwaited<T> =
T extends { then(onfulfilled: (value: infer V) => unknown): unknown } ? MyAwaited<V> : T두 묶음이 보입니다. Partial, Required, Pick, Record는 매핑 타입이라 키 집합을 다룹니다. Exclude, Extract는 분배 조건부 타입이라 유니언 멤버를 다룹니다. Omit은 둘을 이어 붙인 타입이라 keyof T에서 키를 거른 뒤 다시 매핑합니다.
| 유틸리티 | 다루는 것 | 두 번째 인수의 제약 | 도입 |
|---|---|---|---|
Pick<T, K> | T의 키 집합 | K extends keyof T | 2.1 |
Omit<T, K> | T의 키 집합 | K extends keyof any | 3.5 |
Extract<T, U> | T 유니언의 멤버 | 없음 | 2.8 |
Exclude<T, U> | T 유니언의 멤버 | 없음 | 2.8 |
Record<K, T> | K로 만든 키 집합 | K extends keyof any | 2.1 |
Pick과 Extract가 비슷해 보여도 입력이 다릅니다. Pick<User, 'name'>은 객체 타입에서 프로퍼티를 고르고 Extract<keyof A, keyof B>는 키 유니언에서 멤버를 거릅니다. 두 객체 타입의 공통 키만 뽑고 싶을 때 Extract<keyof UserBase, keyof UserProfile>처럼 쓰는 이유입니다.
Omit과 Exclude의 차이는 실무에서 오타로 드러납니다. Omit의 K는 keyof any라 T에 없는 키를 넣어도 오류가 나지 않습니다. Omit<User, 'emial'>은 아무것도 빼지 않은 User를 조용히 돌려줍니다. 반면 Pick은 K extends keyof T라서 같은 오타가 바로 걸립니다. 빼는 키도 검사받고 싶다면 제약을 좁힌 타입을 하나 두면 됩니다.
type StrictOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
type User = { id: string; name: string; email: string; age?: number }
type A = Omit<User, 'emial'> // User 그대로, 오타가 통과합니다
type B = StrictOmit<User, 'emial'> // 오류: 'emial'은 keyof User가 아닙니다파생 타입을 만드는 방향도 여기서 정해집니다. 이전 프로젝트에서는 API 응답 타입을 파일 하나에 모았더니 1,830줄이 되어 협업자가 원하는 타입을 찾지 못했습니다. 그래서 API를 관심사별로 나눈 폴더 구조에 타입도 같이 두고 서버가 주는 응답을 기본 타입으로 삼은 뒤 화면에서 필요한 모양은 Pick, Omit, Partial로 파생하기로 제안했습니다. 매번 새 타입을 선언하면 원본이 무엇인지 찾을 수 없지만 파생 타입은 이름을 따라가면 언제나 원본에 닿습니다. 재귀하는 모양도 같은 방식으로 씁니다. 경로 트리에서 자식에게는 다시 자식을 주지 않으려면 children?: Omit<Route, 'children'>[]처럼 원본에서 한 단계만 덜어 냅니다.
React.FC가 정하는 것과 정하지 못하는 것
FC<P>는 FunctionComponent<P>의 별칭입니다. @types/react 19 기준으로 이 인터페이스는 (props: P) => ReactNode | Promise<ReactNode> 호출 시그니처와 displayName, 그리고 React가 무시한다고 표시된 propTypes만 갖습니다. 한때 FC를 쓰는 이유였던 두 가지는 이제 타입 밖의 일입니다.
children은 React 18 타입부터 암묵적으로 들어오지 않습니다. 자식을 받는 컴포넌트는children?: ReactNode를 직접 선언하거나PropsWithChildren<P>로 감쌉니다. React 18 업그레이드 가이드defaultProps는 React 19에서 함수 컴포넌트 지원이 사라졌고 기본값은 매개변수 기본값으로 씁니다.propTypes검사도 같은 버전에서 제거돼 호출해도 조용히 무시됩니다. React 19 업그레이드 가이드
남는 차이는 반환 타입을 미리 못 박는 것과 제네릭입니다. FC<ListProps<T>>라고 쓰는 순간 T를 묶을 자리가 없습니다. 상수 선언에는 호출마다 타입 인수를 받는 자리가 없으므로 선언 시점에 구체 타입을 하나 고정해야 합니다. 호출할 때마다 항목 타입을 추론하는 컴포넌트는 함수 선언으로 둡니다.
import type { ReactNode } from 'react'
type ListProps<T> = {
items: T[]
getKey: (item: T) => string
renderItem: (item: T) => ReactNode
}
// FC<ListProps<T>>는 T를 받을 자리가 없어 쓸 수 없습니다
function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>
}
// items에서 T가 User로 잡히므로 getKey와 renderItem의 인수도 User입니다
const users: { id: string; name: string }[] = []
const view = <List items={users} getKey={(user) => user.id} renderItem={(user) => user.name} />T가 items에서 한 번 잡히면 같은 호출의 getKey와 renderItem은 그 T로 검사받습니다. 앞 절의 규칙 그대로입니다. 매개변수 자리는 반공변이라 콜백이 더 넓은 타입을 받는 것은 허용되고 더 좁은 타입을 요구하면 오류가 납니다. .tsx 파일에서 화살표 함수로 제네릭을 쓰려면 <T,>처럼 쉼표를 붙여야 합니다. 꺾쇠가 JSX 태그와 구분되지 않아서인데 같은 이유로 .tsx에서는 꺾쇠 단언도 쓸 수 없습니다. TypeScript: JSX
DOM 요소를 감싸는 컴포넌트는 props를 새로 적지 않고 ComponentPropsWithoutRef<'button'>처럼 요소의 props를 가져와 필요한 것만 더합니다. 라이브러리 컴포넌트를 감쌀 때 같은 이름의 props를 그대로 노출하는 기준은 컴포넌트 경계 글의 래퍼 절에 있습니다.
props를 interface로 둘지 type으로 둘지는 핸드북의 기준을 따릅니다. 둘의 핵심 차이는 interface가 같은 이름으로 다시 열어 멤버를 더할 수 있다는 점입니다. 핸드북은 둘 중 무엇이든 괜찮다면서 type의 기능이 필요할 때까지 interface를 쓰라는 경험 규칙을 제시합니다. 컴포넌트에서 type이 필요한 대표 경우는 판별 유니언입니다. 링크처럼 동작하는 버튼과 보통 버튼을 한 컴포넌트로 받으면서 href와 onClick이 섞이지 않게 하려면 유니언이 필요하고 유니언은 interface로 선언할 수 없습니다. Differences Between Type Aliases and Interfaces
type ButtonProps =
| { as: 'button'; onClick: () => void; href?: never }
| { as: 'a'; href: string; onClick?: never }ref는 React 19에서 prop이 되어 제네릭과 함께 쓸 수 있습니다
React 19부터 함수 컴포넌트는 ref를 보통 prop으로 받습니다. React 팀은 새 컴포넌트에 forwardRef가 필요 없고 이후 버전에서 forwardRef를 폐기 예정으로 표시한 뒤 제거하겠다고 밝혔습니다. forwardRef 참고 문서 상단에도 같은 안내가 붙어 있습니다. React 19, forwardRef
이 변화는 제네릭 컴포넌트에서 가장 큽니다. forwardRef(render)는 호출 결과로 새 컴포넌트 값을 만들기 때문에 안쪽 함수가 제네릭이어도 바깥 값에는 타입 매개변수가 남지 않았습니다. React TypeScript Cheatsheet는 이 문제의 우회로로 ref를 보통 prop 이름으로 넘기는 방법을 가장 단순한 선택지로 들고 forwardRef 선언을 모듈 확장으로 다시 적는 방법을 그다음에 둡니다. React 19에서는 첫 번째 우회로가 그냥 정식 방법이 됩니다. Generic Components with Refs
import type { ReactNode, Ref } from 'react'
type ListProps<T> = {
items: T[]
getKey: (item: T) => string
renderItem: (item: T) => ReactNode
ref?: Ref<HTMLUListElement>
}
function List<T>({ items, getKey, renderItem, ref }: ListProps<T>) {
return <ul ref={ref}>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>
}@types/react 19에서는 useRef가 인수를 요구하고 돌려주는 RefObject<T>의 current가 항상 변경 가능해졌습니다. null로 초기화한 ref를 수정할 수 없던 이전의 불편은 사라졌고 MutableRefObject는 폐기 예정으로 표시됐습니다. ref 콜백에서 값을 암묵적으로 돌려주면 정리 함수로 해석되므로 타입 오류가 납니다. 이 세 가지는 업그레이드 가이드의 TypeScript 절에 있고 types-react-codemod의 preset-19가 대부분을 바꿔 줍니다.
추론이 끊기는 자리를 알려 주는 도구가 셋 있습니다
추론을 믿고 타입 선언을 줄이다 보면 두 방향에서 막힙니다. 모양은 검사받고 싶은데 값의 리터럴 타입은 지키고 싶은 경우, 그리고 추론 후보가 너무 넓게 잡히는 경우입니다. 각각에 맞는 문법이 있습니다.
satisfies(4.9)는 식의 타입을 바꾸지 않고 어떤 타입에 맞는지만 검사합니다. 상태별 아이콘 맵을 Record<Status, IconName>으로 선언하면 키 누락과 오타는 잡히지만 statusIcon.success가 IconName 유니언으로 넓어집니다. satisfies를 쓰면 검사는 그대로 받으면서 'check' 리터럴이 남습니다. The satisfies Operator
const 타입 매개변수(5.0)는 호출 안에 쓴 리터럴을 as const 없이 읽기 전용 튜플로 추론합니다. 제약에 readonly가 없으면 추론이 string[]로 되돌아가고 호출 밖에서 선언한 변수를 넘기면 효과가 없습니다. 탭 정의처럼 배열에서 id 유니언을 뽑아 쓰는 자리에 맞습니다. const Type Parameters
NoInfer(5.4)는 특정 인수를 추론 후보에서 뺍니다. 선택지 배열과 초깃값을 함께 받는 함수에서 초깃값이 후보에 들어가면 선택지에 없는 값도 유니언에 합쳐져 통과합니다. 초깃값 쪽을 NoInfer<T>로 감싸면 선택지만 기준이 됩니다. The NoInfer Utility Type
type Status = 'success' | 'error'
type IconName = 'check' | 'close' | 'info'
const statusIcon = { success: 'check', error: 'close' } satisfies Record<Status, IconName>
statusIcon.success // 'check'
function defineTabs<const T extends readonly { id: string }[]>(tabs: T) {
return tabs
}
const tabs = defineTabs([{ id: 'list' }, { id: 'map' }])
type TabId = (typeof tabs)[number]['id'] // 'list' | 'map'
function createSelect<T extends string>(options: T[], initial?: NoInfer<T>) {
return { options, initial }
}
createSelect(['a', 'b'], 'a')
createSelect(['a', 'b'], 'c') // 오류: 'c'는 'a' | 'b'에 없습니다도구를 알게 되면 제네릭을 더 쓰고 싶어집니다. 핸드북의 제네릭 함수 지침은 반대 방향을 가리킵니다. 제약을 걸기보다 타입 매개변수 자체를 쓰고 타입 매개변수를 가능한 한 적게 두며 한 자리에만 나타나는 타입 매개변수는 정말 필요한지 다시 보라는 세 규칙입니다. firstElement<T extends any[]>(arr: T)처럼 제약을 건 쪽은 요소가 any로 풀리고 firstElement<T>(arr: T[])는 number를 돌려줍니다. 컴포넌트 props도 같습니다. 항목 타입 하나로 items와 콜백이 묶이지 않는 제네릭은 호출부에 타입 인수를 적게 만들 뿐입니다. Guidelines for Writing Good Generic Functions
확인 방법과 한계
이 글의 바탕은 2022년 말과 2024년 초에 팀 공유용으로 정리했던 학습 문서 세 편입니다. 그중 infer 정리는 외부 글을 번역한 기록이라 규칙은 핸드북과 2.8, 4.7 릴리스 노트로 다시 확인했고 모든 타입 예제는 TypeScript 7.0.2에서 타입 동치 검사로 결과를 맞춰 봤습니다. React 쪽 주장은 React 18과 19 업그레이드 가이드, @types/react 19의 선언 파일을 기준으로 했으며 TSX 예제는 문서 대조만 했습니다. API 타입을 관심사별로 나누자는 제안은 당시 문서에 남은 논의이고 팀 표준으로 끝까지 적용됐는지는 확인하지 않았습니다. in, out 변성 표기와 forwardRef 제거 시점처럼 버전에 묶인 내용은 글에 적은 버전과 함께 읽어야 합니다.
참고 자료
- TypeScript: Conditional Types
- TypeScript 2.8: Type inference in conditional types
- TypeScript 4.7: extends Constraints on infer Type Variables, Optional Variance Annotations
- TypeScript 4.9: The satisfies Operator
- TypeScript 5.0: const Type Parameters
- TypeScript 5.4: The NoInfer Utility Type
- TypeScript: Utility Types
- TypeScript: Generics
- TypeScript: Guidelines for Writing Good Generic Functions
- TypeScript: Differences Between Type Aliases and Interfaces
- TypeScript: JSX
- React: Using TypeScript
- React 18 Upgrade Guide: TypeScript Definitions
- React 19 Upgrade Guide
- React 19: ref as a prop
- React: forwardRef
- React TypeScript Cheatsheet: Generic Components with Refs
- @types/react index.d.ts