holdanddeepdive / typescript-study

4 stars 0 forks source link

An Introduction To Type Programming In TypeScript #11

Open sbyeol3 opened 1 year ago

sbyeol3 commented 1 year ago

원문 : https://www.zhenghao.io/posts/type-programming

타입 언어를 사용해서 타입 작성하는 법을 배우고 여러분의 자바스크립트 실력을 활용하여 더 빨리 타입스크립트를 마스터할 수 있습니다.

타입은 그 자체로 복잡한 언어다

저는 타입스크립트가 단지 타입 어노테이션이 뿌려져 있는 자바스크립트로 생각하곤 했습니다. 그런 마음가짐으로는 종종 정확한 타입을 작성하는 게 꽤 까다롭고 어렵다는 걸 알게 되었고, 타입들은 제가 만들고자 하는 실제 어플리케이션을 개발하는 데 방해가 되었죠. 그래서 종종 저는 any라는 최후의 해결책에 도달하곤 했고 결국 타입의 안전성까지 잃어버렸습니다.

게다가 여러분이 하는 것에 따라 타입은 아주 복잡해질 수 있습니다. 한동안 타입스크립트로 코드를 쓰고 나서 타입스크립트는 실제로 두 개의 언어로 이루어져 있다는 생각이 들었습니다. 하나는 자바스크립트, 다른 하나는 타입 언어라고 말입니다.

타입스크립트 코드를 작성할 때 우리는 끊임없이 두 세계 사이에서 왔다갔다 해야 합니다. 타입 세계에서는 타입을 만들고, 자바스크립트 세계로 가서 타입 어노테이션으로 타입을 "소환"합니다. (컴파일러에 의해서 암묵적으로 추론되곤 합니다.) 다른 방향으로도 물론 갈 수 있습니다. 자바스크립트 변수/프로퍼티에 대해 typeof 연산자를 사용해서 그에 대한 타입을 가져오기도 합니다. (런타임 값의 타입을 확인하기 위해 제공되는 typeof 연산자를 의미하는 것이 아닙니다.)

image

자바스크립트 언어는 매우 표현적인 특성이 있고 이는 타입 언어도 마찬가지입니다. 실제로 타입 언어가 표현력이 뛰어나서 튜링 완전함이 증명되었습니다.

여기서 튜링 완전성이 좋은지 나쁜지에 대한 가치 판단은 하지 않을 것이며, 그것이 설계에 의한 건지 우연에 의한 건지 알지 못합니다. (사실 종종 튜링 완전성은 우연히 달성되었습니다.) 제가 말하고자 하는 바는 타입 언어 자체는 보이는 것처럼 위험하지 않으며, 아주 강력하고 유능하며 컴파일 시점에서 임의의 연산을 수행할 수 있다는 점입니다.

제가 타입스크립트의 타입 언어를 완전한 프로그래밍 언어로서 생각하기 시작했을 때 이 언어가 함수형 프로그래밍 언어의 몇 가지 특성을 갖고 있다는 것을 깨달았습니다.

이 글에서는 더 빨리 타입스크립트를 마스터하고자 갖고 있는 자바스크립트 지식을 활용할 수 있도록 타입스크립트의 타입 언어를 자바스크립트와 비교하여 배울 것입니다.

이 글은 독자들이 자바스크립트와 타입스크립트에 어느 정도 익숙하다고 가정하고 있습니다. 타입스크립트를 처음부터 배우고자 하신다면 The TypeScript Handbook을 먼저 읽어보세요. 이 글들과 경쟁하는 목적이 아닙니다.

변수 선언

자바스크립트에서 세계는 자바스크립트 값들로 이루어져 있습니다. 그리고 우리는 var, const, let과 같은 키워드로 값들을 참조하기 위해 변수를 선언합니다.

const obj = {name: 'foo'}

타입 언어에서는, 세계는 타입으로 이루어져 있죠. 그리고 typeinterface 키워드로 타입 변수를 선언합니다.

type Obj = {name: string}

"타입 변수"에 대한 더 정확한 이름은 type synonyms 혹은 type alias(타입 별칭)입니다. 그러나 자바스크립트 변수가 값을 참조하는 방식에 대한 비유로서 "타입 변수"라는 단어를 사용합니다.

완벽한 비유가 아닐지라도 타입 변수는 새로운 타입을 만들지 않습니다. 타입 별칭은 이미 있는 타입에 대한 새로운 이름일 뿐입니다. 타입 언어의 개념을 더 쉽게 설명하고자 하는 목적으로 이 비유를 사용합니다.

타입과 값은 서로 관련이 있습니다. 타입의 핵심은 가능한 값들의 집합과 값들에 대해 수행할 수 있고 유효한 작업들을 나타낸다는 것입니다. 때때로 집합은 유한하기도 합니다. 예를 들어 type Name = 'foo' | 'bar' 와 같은 경우에서 말이죠. 그러나 대부분 집합은 무한합니다. type Age = number와 같이 말입니다. 타입스크립트를 쓸 때 우리는 타입과 값을 결합하고 런타임 시점의 값과 컴파일 시점의 타입이 같다는 것을 보장하고자 두 개가 함께 작동하게끔 합니다.

로컬 변수 선언

타입 언어에서 타입 변수를 선언하는 방법에 대해 이야기했습니다. 그러나 타입 변수는 기본적으로 전역 스코프를 가집니다. 로컬 타입 변수를 생성하기 위해 우리는 타입 언어에서 infer 키워드를 사용할 수 있습니다.

type A = 'foo'; // global scope
type B = A extends infer C ? (
    C extends 'foo' ? true : false// **이 표현식에서만** C는 A를 나타냅니다.
) : never

이런 방식으로 스코프를 제한하는 변수를 생성하는 방식이 자바스크립트 개발자에게는 좀 생소해보일지라도, 이런 방식의 뿌리는 몇몇 순수한 함수형 프로그래밍 언어에서 찾을 수 있기도 합니다. 예를 들어 Haskell 언어에서는 특정 스코프를 할당하기 위해 in과 함께 let 키워드를 사용합니다. let {assignments} in {expression}와 같이 작성하는 것이죠.

let two = 2; three = 3 in two * three 
//                         ↑       ↑
// two and three are only in scope for the expression `two * three` 

infer는 중간 타입들을 캐싱하는 데 유용합니다. 관련된 예시를 보여드릴게요.

type ConvertFooToBar<G> = G extends 'foo' ? 'bar' : never
type ConvertBarToBaz<G> = G extends 'bar' ? 'baz' : never

type ConvertFooToBaz<T> = ConvertFooToBar<T> extends infer Bar ? 
        Bar extends 'bar' ? ConvertBarToBaz<Bar> : never 
    : never

type Baz = ConvertFooToBaz<'foo'>

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'>

동등성 비교와 조건 분기

자바스크립트에서 if 문과 함께 =====를 사용하거나 삼항 연산자 ?를 사용해서 같은지를 비교하고 조건 분기를 할 수 있습니다. 반면에, 타입 언어에서는 "동등성 비교"를 위해 extends 키워드를 사용합니다. 또한, 비슷하게 조건 분기를 위해 삼항 연산자 ?를 사용합니다.

TypeC = TypeA extends TypeB ? TrueExpression : FalseExpression

만약 TypeATypeB에 할당 가능하거나 대체될 수 있다면, 첫 번째 분기에 들어가고 TrueExpression에 대한 타입을 얻을 수 있고 그 타입을 TypeC에 할당하게 됩니다. 그렇지 않으면 FalseExpression를 얻어 TypeC에 그 결과를 할당시키겠죠.

할당 가능하다, 대체 가능하다(assignability, substitutability)의 개념은 타입스크립트에서 굉장히 핵심적인 개념 중 하나이기에 이에 관련한 포스팅을 작성하기도 했습니다. 이 글에서 자세히 다루었습니다.

자바스크립트에서 구체적인 예시를 볼까요?

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
}

제너릭 제약조건을 추가함으로써, <T extends {name: string}> 함수가 받는 인자는 항상 string 타입의 name 프로퍼티로 이루어져 있다는 것을 보장할 수 있게 되는 겁니다.

객체 타입에 인덱싱하여 프로퍼티의 타입 가져오기

자바스크립트에서 객체 프로퍼티에 접근하기 위해 obj['prop']와 같이 대괄호를 이용하거나 obj.prop처럼 dot을 사용합니다. 타입 언어에서도 대괄호로 프로퍼티 타입을 가져올 수 있습니다.

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]

함수

함수는 어떠한 자바스크립트 프로그램에서든 가장 기본적은 재사용 가능한 "구성 요소"입니다. 함수는 특정한 input을 받아 output을 리턴해줍니다. 타입 언어에서는 제너릭이 있습니다. 제너릭은 함수처럼 값을 매개변수화하는 타입입니다. 그래서 제너릭은 개념적으로 자바스크립트의 함수와 비슷하기도 합니다.

예를 들어 자바스크립트라면 아래처럼 작성합니다.

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"]

완벽한 비유는 아닐지라도 ...

제너릭은 자바스크립트의 함수와 정확히 동일하지는 않습니다. 자바스크립트 함수와 다르게 제너릭은 타입 언어의 일급 객체가 아닙니다. 즉, 타입스크립트는 함수를 다른 함수로 전달하는 것처럼 제너릭을 다른 제너릭에 전달할 수 없다는 의미가 되겠죠.

map과 filter

타입 언어에서 타입은 불변합니다. 타입의 어느 부분을 수정하고 싶다면 기존의 타입을 새로운 타입으로 변형시켜야만 합니다. 타입 언어에서 데이터 구조(예를 들어 객체)에 걸쳐 반복하고 변형하는 것을 고르게 적용하는 세부 사항들은 Mapped Types에 의해 추상화됩니다. 이를 사용해서 map과 filter와 같은 배열 메소드와 개념적으로 비슷한 동작들을 구현할 수 있습니다.

자바스크립트에서 넘버 프로퍼티를 문자열로 바꾸려먼 아래 코드처럼 작성합니다.

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 유니온 타입으로 전달합니다.

type User = {
    name: string,
    age: number
}

type StringifyProp<T> = {
    [K in keyof T]: string
}

type UserWithStringProps = StringifyProp<User> // { name: string; age: string; }

자바스크립트에서 특정 기준에 따라 객체의 프로퍼티를 필터링할 수 있습니다. 문자열이 아닌 프로퍼티를 필터링하는 예제를 볼까요?

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'}

타입 언어에서는 as 연산자와 never 타입으로 이를 수행할 수 있습니다.

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 키워드를 사용할 수 있습니다. 예를 들어 JS 프로그램에서는 문자열의 특정 부분을 추출하기 위해 정규식을 사용합니다.

const str = 'foo-bar'.replace(/foo-*/, '')
console.log(str) // 'bar'

이 코드를 타입 언어로 바꾸면 아래와 같습니다.

type Str = 'foo-bar'
type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'

반복 대신 재귀

다른 많은 순수한 함수형 프로그래밍 언어가 그렇듯이, 타입 언어 또한 데이터의 목록을 루프를 돌며 반복하는 구문 구조가 없습니다. 재귀 방식이 루프 방식을 대체합니다.

자바스크립트에서는 동일한 아이템을 여러 번 반복하는 배열을 반환하는 함수를 작성한다면 아래처럼 쓸 수 있겠습니다.

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])
}

이 코드를 타입 언어에서 동일하게 동작하려면 어떻게 써야 할까요? 해결책을 찾기 위한 논리적인 단계들이 있습니다.

이 단계들을 타입 언어로 표현한다면 이렇게 쓸 수 있습니다.

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"]    

재귀의 한계

TS 4.5 전에 최대 재귀 깊이는 45까지 였습니다. 4.5 버전에서 꼬리 호출이 최적화됨에 따라 999까지 증가했습니다.

프로덕션 코드에서 타입 ㅇㅇ를 지양하세요

때때로 타입 프로그래밍은 우스갯소리로 "type gymnastics"라고 불리기도 하는데 이는 일반적인 어플리케이션에서 필요한 것보다 훨씬 복잡하고 화려하며 정교할 때를 의미합니다. 예를 들자면,

이런 경우들은 훨씬 더 학술적인 예시이므로 실제 프로덕션 어플리케이션과는 맞지 않습니다.

와 같은 이유들이 있기 때문입니다.

코어 프로그래밍 스킬을 향상시키기 위해 리트코드 문제를 푸는 것처럼 여러분의 타입 프로그래밍 스킬을 위해 type-challenges을 풀어볼 수 있습니다.

마무리

정말 많은 것들을 이 글에서 다루었네요. 이 글의 핵심은 타입스크립트를 가르치는 것이 아니라, 어쩌면 여러분들이 타입스크립트를 배우고 나서 간과했을 수도 있는 "숨겨진" 타입 언어를 다시 소개하는 것입니다.

타입 프로그래밍은 타입스크립트 커뮤니티에서 활발하게 논의가 이루어지고 있지 않은데, 그것이 잘못됐다고 생각하진 않습니다. 궁극적으로 타입을 추가하는 것은 목적을 위한 수단일뿐, 결국 자바스크립트로 신뢰성 있는 웹 어플리케이션을 만드는 게 중요하니까요. 그래서 사람들이 자바스크립트나 다른 프로그래밍 언어들처럼 "적절하게" 타입 언어를 공부하는 데 시간을 들이지 않는 것이 충분히 이해가 됩니다.

앞으로 읽을 것들