타입스크립트를 이용하면 타입 자체를 프로그래밍 하는 것이 가능합니다.
이런 식으로 데이터와 로직이 아닌, 프로그램에 대한 정보를 프로그래밍하는 것을 메타 프로그래밍이라 합니다.
타입스크립트를 활용해 좀 더 풍부한 타입을 만드는 방법의 기초를 다져봅니다.
타입스크립트는 JS에서 벗어나서, 타입만을 가지고도 프로그래밍할 수 있는 타입 프로그래밍 언어입니다.
타입 언어를 사용하여 타입을 작성하고 기존 자바스크립트 지식을 활용하여,
TypeScript를 더 빨리 마스터해 봅시다.
타입스크립트 절망편 : Hacker News
TL;DR
- 전역 변수 선언 : 타입 변수 선언(type,interface)
- 로컬 변수 선언 : infer 키워드
- JS엔 없음;하스켈 예제 (let two = 2; three = 3 in two * three)
- type ConvertFooToBaz<T> = ConvertFooToBar<T> extends 'bar' ? ConvertBarToBaz<ConvertFooToBar<T> > : never
- 조건 분기와 동등 검사 : ? 연산자와 extends 키워드
- JS : username === 'foo' ? true : false
- Type : type Matched = Username extends 'foo' ? true : false // true
- function getUserName<T extends {name: string}>(user: T)
- 객체에서 인덱싱 : Type['key'] ,Type[number]
- 함수
- 함수명 : 타입명
- 파라미터 : 제네릭
- function fn(a, b = 'world')
- type Fn <A extends string, B extends string = 'world'>
- 파라미터 명과 파라미터 타입, 파라미터 기본값
- 함수 본문과 리턴문 : {}와 =
- JS : { return [a, b] }
- Type : = [A, B]
- 타입 언어의 경우 반드시 expression 형태로 사용해야 하기에, 삼항 연산자를 중첩해야 한다는 것을 명심
- 맵과 필터 메서드 : Mapped Type, Mapped Type + as
- map
- JS : Object.fromEntries(Object.entries(object) .map(([key, value]) => [key, String(value)]))
- const user = { name: 'foo', age: 28 } > {name:'foo', age: '28'}
- Type : type StringifyProp<T> = { [K in keyof T]: string }
- type User = { name: string, age: number } > { name: string; age: string; }
- JS : Object.fromEntries(Object.entries(object) .map(([key, value]) => [key, String(value)]))
- filter
- JS : Object.fromEntries(Object.entries(object) .filter(([key, value]) => typeof value === 'string' && [key, value]))
- const user = { name: 'foo', age: 28 } > {name: 'foo'}
- Type : type FilterStringProp<T> = { [K in keyof T as T[K] extends string ? K : never]: string }
- type User = { name: string, age: number } > { name: string }
- JS : Object.fromEntries(Object.entries(object) .filter(([key, value]) => typeof value === 'string' && [key, value]))
- map
- 패턴 매칭 : 탬플릿 리터럴 타입
- JS : const str = 'foo-bar'.replace(/foo-*/, '')
- Type : type Str = 'foo-bar' ; type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'
- 반복 대신 재귀
- 함수 선언 : 제네릭 선언
- JS : function fillArray(item, n, array = [])
- Type : type FillArray<Item, N extends number, Array extends Item[] = []>
- base 조건(판단을 활용한 분기):
- JS : array.length === n ? array : fillArray(item, n, [item, ...array]);
- Type : Array['length'] extends N ? Array : FillArray<Item, N, [...Array, Item]>
- 함수 본문과 리턴문
- JS : { return array.length === n ? array : fillArray(item, n, [item, ...array]) }
- Type : = Array['length'] extends N ? Array : FillArray<Item, N, [...Array, Item]>;
- 함수 선언 : 제네릭 선언
타입은 그 자체로 강력한 기능을 가진 언어입니다.
- JavaScript 언어의 경우 세계은 JavaScript 값으로 구성됩니다.
- Type 언어의 경우 세계는 타입으로 구성됩니다.
JavaScript 언어는 표현력이 뛰어나고 타입 언어도 마찬가지입니다.
사실, 타입 언어는 표현력이 매우 뛰어나 튜링 완전한 것으로 입증되었습니다.
(주 : 튜링 완전하다는 말은 컴퓨터가 할수 있는 일을 전부 코딩 가능하다는 의미입니다.)
- 반복 대신 재귀 사용
- TypeScript 4.5에는 꼬리 호출에 최적화된 재귀가 있습니다
- 타입(데이터)은 변경할 수 없습니다.(immutable)
이를 이용해, JavaScript의 함수형 프로그래밈 기술을 활용하여 TypeScript의 타입 언어를 배워봅니다.
전역 변수 선언
const obj = {name: 'foo'}
type Obj = {name: string}
타입 변수(type variables)의 보다 정확한 이름은 타입 동의어(type synonyms) 또는 타입 별칭(type alias)입니다.
이 게시물은 설명을 위해 JavaScript 변수가 값을 참조하는 방식과 유사하게 "타입 변수"라는 단어를 사용합니다.
완벽한 비유는 아니지만 타입 별칭은 새 타입을 만들거나 도입하지 않으며 기존 타입의 또 하나의 새 이름일 뿐입니다.
더 쉬운 이해를 위한 비유입니다.
- 때로는 집합이 유한합니다(예: type Name = 'foo' | 'bar')
- 집합이 무한인 경우도 많습니다(예: type Age = number).
로컬 변수 선언
type A = 'foo'; // global scope
type B = A extends infer C ? (
C extends 'foo' ? true : false// *only* inside this expression, C represents A
) : never
let two = 2; three = 3 in two * three
// ↑ ↑
// two 와 three 는 표현식 `two * three` 에서만 사용 가능합니다.
type ConvertFooToBar<G> = G extends 'foo' ? 'bar' : never
type ConvertBarToBaz<G> = G extends 'bar' ? 'baz' : never
// 타입 언어에서는 "동등성 검사"에 extends 키워드를 사용
type ConvertFooToBaz<T> = ConvertFooToBar<T> extends infer Bar ?
Bar extends 'bar' ? ConvertBarToBaz<Bar> : never
: never
// 주 : ConvertFooToBar<T> <: Bar <: 'bar'
type Baz = ConvertFooToBaz<'foo'>
// G extends 'foo' => ConvertFooToBar<T>는 'bar' => Bar는 'bar' 따라서 'bar'를 'baz'로 변환
infer가 없으면 Bar를 두 번 계산해야 합니다.
type ConvertFooToBar<G> = G extends 'foo' ? 'bar' : never
type ConvertBarToBaz<G> = G extends 'bar' ? 'baz' : never
type ConvertFooToBaz<T> = ConvertFooToBar<T> extends 'bar' ?
ConvertBarToBaz<ConvertFooToBar<T> > : never // call `ConvertFooToBar` twice
type Baz = ConvertFooToBaz<'foo'>
조건 분기와 동등성 검사
주 : 실제로 A extends B 는 A is a subset/subtype of B 로 읽는게 더 적절합니다.
TypeC = TypeA extends TypeB ? TrueExpression : FalseExpression
TypeA가 TypeB에 할당 가능하거나 대체 가능하면 첫 번째 분기에서 TrueExpression에서 타입을 가져와 TypeC에 할당합니다.
그렇지 않으면 FalseExpression 타입을 가져옵니다.
JavaScript의 예
const username = 'foo'
let matched
if(username === 'foo') {
matched = true
} else {
matched = false
}
타입 언어로 번역
type Username = 'foo'
type Matched = Username extends 'foo' ? true : false // true
extends 키워드는 다양한 역할을 합니다. 제네릭 타입 파라미터에 제약 조건을 적용할 수도 있습니다.
function getUserName<T extends {name: string}>(user: T) {
return user.name
}
주 : 타입스크립트에서 인터페이스는 모양 검사만 합니다.
객체에서 인덱싱하여 속성 타입 검색
- obj['prop'] 또는 점 연산자(예: obj.prop).
타입 언어에서도 대괄호를 사용하여 속성 타입을 추출할 수 있습니다.
type User = {name: string, age: number}
type Name = User['name']
객체 뿐만 아니라, 튜플 및 배열을 사용하여 타입을 인덱싱할 수도 있습니다.
type Names = string[]
type Name = Names[number]
type Tuple = [string, number]
type Age = Tuple[1]
함수
JavaScript 예제
function fn(a, b = 'world') { return [a, b] }
const result = fn('hello') // ["hello", "world"]
타입 언어
type Fn <A extends string, B extends string = 'world'> = [A, B]
// ↑ ↑ ↑ ↑ ↑
// name parameter parameter type default value function body/return statement
type Result = Fn<'hello'> // ["hello", "world"]
사실 제네릭은 JavaScript의 함수와 결코 동일하지 않습니다.
우선 JavaScript의 함수와 달리 Generics는 타입 언어의 일급 시민이 아닙니다.
즉 함수를 인자로 넘길수 있는 JS와 달리, 제네릭을 타입 파라미터 위치에 전달할 수 없습니다.
즉, TypeScript는 타입 매개변수로 제네릭을 허용하지 않습니다.
따라서 다른 함수에 함수를 전달하는 것처럼 제네릭을 다른 제네릭에 전달할 수 없습니다.
맵과 필터 메서드
- map 함수는 mapped types와 개념적으로 유사합니다.
- filter는 mapped types + as의 조합으로 구현 가능합니다.
const user = {
name: 'foo',
age: 28
}
function stringifyProp(object) {
return Object.fromEntries(Object.entries(object)
.map(([key, value]) => [key, String(value)]))
}
const userWithStringProps = stringifyProp(user) // {name:'foo', age: '28'}
타입 언어에서 매핑은 이 구문 [K in keyof T]를 사용하여 수행됩니다.
여기서 keyof 연산자는 속성 명 각각을 string literal로 union한 string union type을 제공합니다.
type User = {
name: string,
age: number
}
type StringifyProp<T> = {
[K in keyof T]: string
}
type UserWithStringProps = StringifyProp<User> // { name: string; age: string; }
JavaScript에서는 몇 가지 기준에 따라 객체의 속성을 걸러낼 수 있습니다.
예를 들어 문자열이 아닌 모든 속성을 필터링할 수 있습니다.
const user = {
name: 'foo',
age: 28
}
function filterNonStringProp(object) {
return Object.fromEntries(Object.entries(object)
.filter(([key, value]) => typeof value === 'string' && [key, value]))
}
const filteredUser = filterNonStringProp(user) // {name: 'foo'}
type User = {
name: string,
age: number
}
type FilterStringProp<T> = {
[K in keyof T as T[K] extends string ? K : never]: string
}
type FilteredUser = FilterStringProp<User> // { name: string }
패턴 매칭
또한 infer 키워드를 사용하여 타입 언어에서 패턴 매칭을 수행할 수 있습니다.
예를 들어 Javascript 프로그램에서 정규식을 사용하여 문자열의 일부를 추출할 수 있습니다.
const str = 'foo-bar'.replace(/foo-*/, '')
console.log(str) // 'bar'
타입 언어에서는 탬플릿 리터럴 타입을 활용해 이를 구현합니다.
type Str = 'foo-bar'
type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'
반복 대신 재귀
많은 순수 함수형 프로그래밍 언어처럼
우리의 타입 언어에는 데이터 목록을 반복하는 for 루프 구문이 없습니다.
재귀는 루프를 대신합니다.
JavaScript에서 동일한 item이 n 반복되는 배열을 반환하는 함수를 작성한다고 가정해 보겠습니다.
아래와 같이 구현하는 것이 가능합니다.
function fillArray(item, n) {
const res = [];
for (let i = 0; i < n; i++) {
res[i] = item;
}
return res;
}
이를 아래와 같이 재귀로 구현 가능합니다.
function fillArray(item, n, array = []) {
return array.length === n ? array : fillArray(item, n, [item, ...array])
}
- FillArray라는 제네릭 타입을 생성합니다.(타입 언어의 제네릭이 함수와 같다고 말한 것을 기억하시나요?)
- FillArray<Item, N extends number, Array extends Item[] = []>
-
"함수 본문" 내에서 extends 키워드를 사용하여 Array의 길이 속성이 이미 N인지 확인해야 합니다.
-
N(base case or edge case)에 도달하면 단순히 Array를 반환합니다.
-
N에 도달하지 않은 경우 재귀하여 Array에 항목을 하나 더 추가합니다.
-
type FillArray<Item, N extends number, Array extends Item[] = []>
= Array['length'] extends N
? Array : FillArray<Item, N, [...Array, Item]>;
type Foos = FillArray<'foo', 3> // ["foo", "foo", "foo"]
참고 : 재귀 깊이에 대한 제한
TypeScript 4.5 이전에는 최대 재귀 깊이가 45였습니다.
TypeScript 4.5에서 꼬리 호출 최적화가 적용되어 제한이 999로 증가했습니다.
프로덕션 코드에서 타입 체조를 피하세요
때때로 타입 프로그래밍은 일반적인 응용 프로그램에서 필요로 하는 것보다 불필요하게 복잡해지는 경향이 있습니다.
이를 타입 체조라고 하며, 되도록이면 지양해야 합니다.
관심있다면 아래 게시물을 체크해 보세요
- 난해한 TypeScript 기능은 정말 이해하기 어렵습니다.
- 엄청나게 길고 비밀스러운 컴파일러 오류 메시지로 인해 디버그하기 어렵습니다.
- 컴파일 속도가 느립니다.
핵심 프로그래밍 기술을 연습하기 위한 Leetcode가 있는 것처럼 타입 프로그래밍 기술을 연습하기 위한 타입 챌린지가 있습니다.
type-challenges
마무리
참고
타입 자체에 대한 좀 더 깊은 이해를 위해 참고할 블로그를 추천합니다.
'FrontEnd' 카테고리의 다른 글
[번역] 모든 CSS 레이아웃 방법론을 알아보자 (0) | 2023.02.03 |
---|---|
[번역] CSS 레이아웃과 블록 서식 맥락 이해하기 (0) | 2023.02.02 |
[번역] 빅테크 프론트엔드 기술 인터뷰 : Html편 (0) | 2023.02.01 |
[번역] 빅테크 프론트엔드 기술 인터뷰 : CSS편 (0) | 2023.02.01 |
[번역] 집합론으로 이해하는 타입스크립트 (0) | 2023.01.31 |