2.2Generic Types
함수/클래스/타입이 타입을 인자로 받는 Generic Type
TL;DR
추억의 쪽지 시험
Generic Type
Generic Type란, 함수/클래스/타입이 값이 아닌 타입을 인자로 받는 문법이다. 이를 통해 타입스크립트가 보다 정확하게 타입을 추론할 수 있게 되고, 함수/클래스/타입의 재사용성이 좋아진다.
예를 들어, Generic Type이 아닌 아래의 DataStore 타입은 키는 문자열, 값은 문자열 또는 숫자만 받기에 불리언 값을 가진 속성을 추가할 수 없다.
typescripttype DataStore = {[key: string]: string | number};const store: DataStore = {};store.name = 'sungyup';store.isDeveloper = true; // 에러! 불리언 값은 type에 없다.
Generic Type은 <>안에 placeholder를 두고, 이후 실제 해당 타입을 사용할 때 타입을 인자로 받아 placeholder 자리에 적용한다. 보통 대문자 T가 많이 쓰인다.
typescripttype DataStore<T> = {[key: string]: T};const store: DataStore<string | boolean> = {};store.name = 'sungyup';store.isDeveloper = true; // T가 string 또는 boolean이라 boolean도 가능하다.
타입 뿐 아니라 함수 또는 클래스를 정의할때도 Generic을 활용할 수 있다.
함수의 예
함수에선 특히 입력과 출력의 타입이 서로 연결되어야 하는 경우가 많다. 범용적인 함수를 만든다고 any를 썼다가는 아무거나 받아서 아무거나 반환하는 함수를 만들수도 있다. 이 때, Generic을 활용하면 T라는 타입을 받아 T를 반환하는 함수를 만들 수 있게 된다.
typescriptfunction merge<T>(a: T, b: T) { return [a, b]; } const ids = merge<number>(1, 2); // <number>은 생략되어도 타입스크립트가 추론할 수 있다.
클래스의 예
typescriptclass User<T>{ constructor(public id: T){} }; const user = new User('i1'); user.id;
또, Generic을 쓸 때 인자 여러 개를 동시에 쓸 수도 있다. 이 때 주의해야할 것은 반환 타입도 명시적으로 작성해야할 경우가 많다는 것이다. 예를 들어, 아래의 예시에서 반환 타입을 지정하지 않는다면 타입스크립트는 (T|U)[]로 반환 타입을 추론할 것이다.
typescriptfunction merge<T, U>(a: T, b: U): [T, U] {return [a, b];}const ids = merge(1, 'sungyup'); // U라는 다른 generic이기 때문에 b 자리엔 다른 타입이 올 수 있다.
Type Constraints
Generic의 핵심은 타입 제한이다. 사실, <T>를 받는다고만 하면 any로 지정하는 것과 비교했을 때 큰 이점이 없어지므로 extends를 활용해 특정 속성은 반드시 포함한 객체 등의 타입을 지정하는 패턴이 자주 쓰인다.
typescript// 1) 제약: length가 있는 것만 function sized<T extends { length: number }>(x: T) { return x.length; } // 2) 안전한 프로퍼티 접근 function getProp<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { id: '1', age: 33 }; const age = getProp(user, 'age'); // number // 3) 키 제약(Record 등) function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T,K> { const out = {} as Pick<T, K>; for (const k of keys) out[k] = obj[k]; return out; }
다음 포스팅에서는 보다 실전적인 사용법에 대해 살펴보자.
🤠 개인 탐구
개인적으로 Generic Type은 라이브러리를 쓸 때, import한 함수나 클래스, 변수, 타입이 어떻게 구현되었는지 따라가보다 d.ts 파일을 열면서 본 적이 많았다. 이 파일들은 선언 파일(Declaration File)로, 실제 코드 구현이 아닌 타입 정보를 담아 개발자들이 타입 안전하게 쓸 수 있도록 존재한다.
몇몇 라이브러리에서 본 d.ts 파일의 Generic 문법을 분석해보면서 공부하고자 한다.
1. react-cookie의 useCookies
react-cookie는 React 애플리케이션에서 쿠키를 쉽게 쓸 수 있도록 도와주는 라이브러리다. 여기서 useCookies 훅은 아래와 같은 d.ts 파일에 타입이 정의되어 있다.
typescriptimport { Cookie, CookieSetOptions, CookieGetOptions } from 'universal-cookie'; export default function useCookies<T extends string, U = { [K in T]?: any; }>(dependencies?: T[], options?: CookieGetOptions): [ U, (name: T, value: Cookie, options?: CookieSetOptions) => void, (name: T, options?: CookieSetOptions) => void, () => void ];
1-1. import
첫 줄, import는 아래의 types.d.ts에서 타입 3개를 가져온다. http에서 쿠키는 문자열로 주고 받지만, 아래에 보다시피 Cookie의 타입은 any인데, 이는 이 라이브러리가 개발 편의상 문자열 외의 값도 받아서 직렬화와 역직렬화를 해주는 것으로 기대할 수 있다.
typescriptexport type Cookie = any; export interface CookieGetOptions { doNotParse?: boolean; doNotUpdate?: boolean; } export interface CookieSetOptions { path?: string; expires?: Date; maxAge?: number; domain?: string; secure?: boolean; httpOnly?: boolean; sameSite?: boolean | 'none' | 'lax' | 'strict'; partitioned?: boolean; }
1-2. Generic 부분
typescriptimport { Cookie, CookieSetOptions, CookieGetOptions } from 'universal-cookie';export default function useCookies<T extends string, U = {[K in T]?: any;}>(dependencies?: T[], options?: CookieGetOptions): [U,(name: T, value: Cookie, options?: CookieSetOptions) => void,(name: T, options?: CookieSetOptions) => void,() => void];
<T extends string, U = { [K in T]?: any }>는 제네릭 파라미터다.
우선 결론부터 말하면, T Extends string은 T가 'name' | 'noPopup'과 같은 문자열 리터럴들의 유니언이라는 의미다. 보다 구체적으로는, 이후에 나오겠지만 유저가 지정하는 쿠키의 이름이다.
T가 string의 subtype이어야 한다는 뜻으로, string 타입이나 더 구체적인 타입, 즉 문자열 리터럴 타입 또는 그것들의 유니언 타입이라는 의미다. 만약 모든 string이 가능한 것이면 그냥 string을 타입으로 지정하면 되었겠지만, 문자열이긴 한데 모든 문자열을 허용하지 않고 설정한 문자열 리터럴 타입만으로 제한하기 위해 이렇게 쓰는 것이기 때문이다. 또, 이 케이스에서는
U의 정의 때문에 더더욱 T가 문자열 리터럴의 유니언 타입을 의도했음을 알 수 있는데, 만약 T가 string이면 U는 { [K in string]: string}으로 거의 Record<string, string>과 같이 너무 타입이 넓어져 타입 안정성에 도움이 안 되기 때문이다. 따라서 T extends string은 문자열 리터럴 유니언을 의도했지만, 타입 시스템 상 강제할 수는 없으니 이렇게 표현한 것이다.U = { [K in T]?: any}는 {k : v} 형태라는데에서 우선 mapped type, 즉 키-값 쌍을 가지는 타입임을 알 수 있다. 여기서 key인 [K in T]는 K가 문자열 리터럴의 유니언으로 정의된 T의 모든 멤버(유니언의 각 원소)를 순회하며 만들어진다는 의미로, 만약 T가 'name' | 'noPopup'이라면 U객체의 키는 name과 noPopup이 된다. 여기서 ?로 인해 각 key는 옵셔널이 되고, 값의 타입은 any다.
따라서 U는 { name?: any; noPopup?: any }라고 볼 수 있다.
이렇게 <>로 둘러쌓인 T와 U, 즉 제네릭 파라미터는 그 자체로는 useCookies 훅의 구체적인 타입을 설명하진 않는다. 다만, 이후에 나올 훅의 시그니처(파라미터/반환값/내부 타입 표현) 안에서 타입 자리에 쓰여 설명을 돕는 역할을 한다.
1-3. 파라미터 부분
typescriptimport { Cookie, CookieSetOptions, CookieGetOptions } from 'universal-cookie';export default function useCookies<T extends string, U = {[K in T]?: any;}>(dependencies?: T[], options?: CookieGetOptions): [U,(name: T, value: Cookie, options?: CookieSetOptions) => void,(name: T, options?: CookieSetOptions) => void,() => void];
useCookies 훅은 2개의 파라미터를 옵션으로 받는다.
dependencies?: T[]:T의 배열이다. 제네릭 파라미터에T extends string을 설정해두었기 때문에, 아무string이나 넣지 못하고 제네릭T에 맞춘 이름만 넣을 수 있게 제한된다.options?: CookieSetOptions: 앞서 본 것과 같이 경로, 만기,httpOnly등을 설정할 수 있는 옵션이다.
1-4. 반환 타입(튜플)
typescriptimport { Cookie, CookieSetOptions, CookieGetOptions } from 'universal-cookie';export default function useCookies<T extends string, U = {[K in T]?: any;}>(dependencies?: T[], options?: CookieGetOptions): [U,(name: T, value: Cookie, options?: CookieSetOptions) => void,(name: T, options?: CookieSetOptions) => void,() => void];
길이가 4인 튜플이 반환된다. 여기서, 이해를 돕기 위해 useCookies의 문법을 보면 이런 식이다:
typescriptconst [cookies, setCookie, removeCookie, updateCookies] = useCookies(['cookie-name']);
이 문법을 토대로 반환값들을 해석해보자:
U: 쿠키 객체다. 키는T에 있는 문자열 리터럴들, 값은any다.(name: T, value: Cookie, options?: CookieSetOptions) => void:setCookie는T에 있는 쿠키의 이름,Cookie타입으로 변환되었을 값 및 옵션을 받아 쿠키를 설정하고 반환은 없는 함수다.(name: T, options?: CookieSetOptions) => void: 쿠키의 이름과 옵션을 선택적으로 받아 쿠키를 제거하고 반환은 없는 함수다.() => void: 공식 문서에 따르면updateCookies는 자동으로 라이브러리에서 해결하므로 사실상 쓸 일이 없다.
2. lodash의 debounce
lodash는 많은 편의성 함수들을 제공하는 라이브러리다. 그 중 debounce 함수는 디바운싱을 쉽게 구현할 수 있게 돕는 유틸 함수로, 연속적으로 발생한 이벤트에서 마지막 또는 처음의 상태만을 함수로 실행하는 기능을 제공한다. 예를 들어 사용자가 창 크기 조정을 멈출때까지 기다렸다가 resizing event를 반영하고 싶을때, 창 크기 조정을 하는 동안 기다리다가 마지막 상태를 실행하면 된다.
typescriptinterface LoDashStatic { debounce<T extends (...args: any) => any>(func: T, wait: number | undefined, options: DebounceSettingsLeading): DebouncedFuncLeading<T>; debounce<T extends (...args: any) => any>(func: T, wait?: number, options?: DebounceSettings): DebouncedFunc<T>; }
똑같은 debounce에 대한 정의가 2번 다르게 되어 있는데, 이는 TypeScript Overloads(오버로드된 함수 시그니처)라는 기법으로, 전달한 옵션에 따라 더 정확한 타입으로 돌려주기 위해 나눈 것이다.
첫 번째 시그니처는 options가 DebounceSettingsLeading일 때 적용된다. 즉, 처음의 상태만을 실행하는 leading 옵션일 때 특별히 DebouncedFuncLeading<T>라는 타입으로 반환하고, 나머지는 DebouncedFunc<T>로 반환하게 된다. 이렇게 구분한 이유는 첫 상태만 실행할땐 trailing, 즉 진행중일 때에 대한 호출이 없기 때문에 관련된 메소드도(이후에 나오는 flush()) 영향을 받아 타입을 엄밀하게 정의하고 싶기 때문이다.
아무튼, Generic과 관련된 내용을 살펴보면 <T extends (...args: any) => any>가 쓰였는데, (...args)는 자바스크립트의 rest parameter(가변 인자) 문법으로 인자를 여러개 받을 수 있다는 의미다. 이 인자들의 타입이 any고, 반환값도 any라는 말은 사실상 "모든 함수"라는 의미다. 즉, 모든 함수에 대해 debounce를 제공하되 이후 반환값에도 들어있는 <T>를 보면 알 수 있듯 아무 함수나 다시 반환하는데 쓰이는게 아니고 인자로 받은 그 함수를 반환 타입에 쓰는 것이다.
반환값들의 타입인 DebouncedFunc와 DebouncedFuncLeading은 아래와 같이 정의되어 있다:
typescriptinterface DebouncedFunc<T extends (...args: any[]) => any> { (...args: Parameters<T>): ReturnType<T> | undefined; cancel(): void; flush(): ReturnType<T> | undefined; } interface DebouncedFuncLeading<T extends (...args: any[]) => any> extends DebouncedFunc<T> { (...args: Parameters<T>): ReturnType<T>; flush(): ReturnType<T>; }
이 반환 타입들은 호출 가능한 인터페이스로, 맨 첫 줄은 이 함수의 콜 시그니처이다. 즉, 이 interface들은 함수 + 부가 메서드들이 달린 객체의 타입이다. 이 반환값 함수의 파라미터는 T의 파라미터고((...args: Parameters<T>), 반환값은 ReturnType<T>와 leading 여부에 따라 undefined일 수도 있다.
여기서 왜 앞서 leading 여부에 따라 반환 타입을 달리했는지 알 수 있는데, debounce의 특성상 호출 시점에 값이 있을지 없을지는 leading이면 바로 호출하니 값이 존재하지만 그 외의 경우에는 undefined인 것이다. 또, leading에만 실행될 경우 trailing=false이므로 cancel()은 취소할 호출이 없어 필요가 없어 타입에서 빠진 것으로 볼 수 있다.