본문 바로가기

FrontEnd

[번역] 타입스크립트를 활용한 타입 프로그래밍 입문

반응형

타입스크립트를 이용하면 타입 자체를 프로그래밍 하는 것이 가능합니다.
이런 식으로 데이터와 로직이 아닌, 프로그램에 대한 정보를 프로그래밍하는 것을 메타 프로그래밍이라 합니다.

타입스크립트를 활용해 좀 더 풍부한 타입을 만드는 방법의 기초를 다져봅니다.

원문은 아래에서 확인하실 수 있습니다.

타입스크립트는 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; }
    • filter
      • JS : Object.fromEntries(Object.entries(object) .filter(([key, value]) => typeof value === 'string' && [key, value]))
        • const user = { name'foo'age28 }  >  {name: 'foo'}
      • Type : type FilterStringProp<T> = { [K in keyof T as T[K] extends string ? K : never]: string }
        • type User = { namestringagenumber } >  { name: string }
  • 패턴 매칭 : 탬플릿 리터럴 타입
    • JS : const str = 'foo-bar'.replace(/foo-*/, '')
    • Type : type Str = 'foo-bar' ; type Bar = Str extends `foo-${infer rest}` ? rest : never // 'bar'
  • 반복 대신 재귀 
    1. 함수 선언 : 제네릭 선언
      • JS : function fillArray(item, n, array = [])
      • Type : type FillArray<Item, N extends number, Array extends Item[] = []>
    2. base 조건(판단을 활용한 분기): 
      • JS : array.length === n ? array : fillArray(item, n, [item, ...array]);
      • Type : Array['length'] extends N ? Array : FillArray<Item, N, [...Array, Item]>
    3. 함수 본문과 리턴문
      • JS : { return array.length === n ? array : fillArray(item, n, [item, ...array]) }
      • Type : = Array['length'] extends N ? Array : FillArray<Item, N, [...Array, Item]>;

타입은 그 자체로 강력한 기능을 가진 언어입니다.

TypeScript를 타입 주석(type annotation)이 있는 JavaScript로 생각했습니다.
그런 마음가짐으론 올바른 타입을 작성하는 것이 까다롭고 벅차다는 것을 알게 되었습니다.
 
TypeScript 언어는 실제로 두 개의 하위 언어로 구성되어 있다고 생각할 수 있습니다.
하나는 JavaScript이고 다른 하나는 Type 언어입니다.
  • JavaScript 언어의 경우 세계은 JavaScript 으로 구성됩니다.
  • Type 언어의 경우 세계는 타입으로 구성됩니다.
TypeScript 코딩은 이 JS세계와 Type 세계 사이에서 끊임없이 춤추는 것입니다.
우리는 타입 세계에서 타입을 만들고 타입 주석을 사용하여 JavaScript 세계에서 타입을 "소환"합니다.
또는 컴파일러에서 암시적으로 추론하도록 합니다
 
반대로, JavaScript 변수/속성에서 TypeScript의 typeof 연산자를 사용하여 해당 타입을 검색할 수 있습니다.
(JavaScript가 런타임 값의 타입을 확인하기 위한 typeof 연산자가 아닙니다; typeof 는 타입 레벨, 값 레벨에서 둘 다 사용 가능합니다.)

TypeScript 코딩은 타입과 자바스크립트 세계 사이에서 끊임없이 춤추는 것입니다.

JavaScript 언어는 표현력이 뛰어나고 타입 언어도 마찬가지입니다.
사실, 타입 언어는 표현력이 매우 뛰어나 튜링 완전한 것으로 입증되었습니다.
(주 : 튜링 완전하다는 말은 컴퓨터가 할수 있는 일을 전부 코딩 가능하다는 의미입니다.)

Turing 완전한 것이 좋은지 나쁜지에 대해 이야기 하는 것이 아니며,
그것이 설계에 의한 것인지 우연한 것인지도 알지 못합니다만(실제로 종종 Turing-completeness는 우연히 달성되었습니다),
 
타입 언어는 상당히 강력하며, 컴파일 시간에 임의의 계산을 수행할 수 있습니다.
(즉, 프로그램이 할 수 있는 모든 연산이 가능합니다.)
 
이처럼 TypeScript의 타입 언어를 본격적인 프로그래밍 언어로 생각하기 시작했을 때,
타입 언어가 함수형 프로그래밍 언어의 몇 가지 특성을 갖고 있다는 것을 알게 되었습니다.
  • 반복 대신 재귀 사용
    • TypeScript 4.5에는 꼬리 호출에 최적화된 재귀가 있습니다
  • 타입(데이터)은 변경할 수 없습니다.(immutable)

이를 이용해, JavaScript의 함수형 프로그래밈 기술을 활용하여 TypeScript의 타입 언어를 배워봅니다.


전역 변수 선언

JavaScript에서 세상은 JavaScript 값으로 이루어지며 var, const 및 let 키워드를 사용하여 값을 참조하는 변수를 선언합니다.
const obj = {name: 'foo'}
 타입 언어에서 세상은 타입으로 이루어져 있고, 우리는 type과 interface를 키워드로 하여 타입 변수(type variables)를 선언합니다.
type Obj = {name: string}
타입 변수(type variables)의 보다 정확한 이름은 타입 동의어(type synonyms) 또는 타입 별칭(type alias)입니다.
이 게시물은 설명을 위해 JavaScript 변수가 값을 참조하는 방식과 유사하게 "타입 변수"라는 단어를 사용합니다.
완벽한 비유는 아니지만 타입 별칭은 새 타입을 만들거나 도입하지 않으며 기존 타입의 또 하나의 새 이름일 뿐입니다.
더 쉬운 이해를 위한 비유입니다.
타입과 값은 매우 관련이 있습니다.
타입은 가능한 값 집합(1,2,3...)과 값에 대해 수행할 수 있는 유효한 작업(+)을 나타냅니다.
  • 때로는 집합이 유한합니다(예: type Name = 'foo' | 'bar')
  • 집합이 무한인 경우도 많습니다(예: type Age = number).
TypeScript를 통해 타입과 값을 통합하고 런타임 값의 타입이 컴파일 타임 타입과 일치하도록 합니다.

로컬 변수 선언

위 문단에서 타입 언어에서 타입 변수를 생성하는 방법에 대해 이야기했습니다.
타입 변수는 기본적으로 전역 범위를 갖습니다.
타입 언어에서는 로컬 타입 변수를 생성하기 위해 infer 키워드를 사용할 수 있습니다.
type A = 'foo'; // global scope
type B = A extends infer C ? (
    C extends 'foo' ? true : false// *only* inside this expression, C represents A
) : never
scoped 변수를 생성하는 이 특별한 방법은 JavaScript 개발자에게 이상하게 보일 수 있지만,
사실 순수 함수형 프로그래밍 언어에 뿌리가 있는 방식입니다.
 
예를 들어, Haskell에서는 let {assignments} in {expression}에서와 같이
범위 지정 할당을 수행하기 위해 in과 함께 let 키워드를 사용할 수 있습니다.
let two = 2; three = 3 in two * three 
//                         ↑       ↑
// two 와 three 는 표현식 `two * three` 에서만 사용 가능합니다.
infer 키워드는 일부 중간 타입을 캐싱하는 데 유용합니다.
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'>

조건 분기와 동등성 검사

자바스크립트에서 if 문이나 조건부(삼항) ? 연산자와 함께 ===/==를 사용할 수 있습니다. 동등 검사 와 조건부 분기를 수행합니다.
타입 언어에서는 "동등성 검사"에 extends 키워드를 사용하고
조건부 분기에 조건부(삼항) 연산자 ? 를 사용합니다. (extends는 equal, ?는 분기)
 
주 : 실제로 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
}
제네릭 조건을 추가하여 <T extends {name: string}> 함수가 취하는 인자 객체가 항상 string 타입의 name 속성으로 설정되도록 합니다.
주 : 타입스크립트에서 인터페이스는 모양 검사만 합니다.

객체에서 인덱싱하여 속성 타입 검색

JavaScript에서는 대괄호를 사용하여 객체 속성에 액세스할 수 있습니다.
  • 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]

함수

함수는 모든 JS 프로그램의 재사용 가능한 주요 "컴포넌트"입니다.
일부 입력(일부 자바스크립트 값)을 받고 출력(일부 자바스크립트 값)을 반환합니다.
 
타입 언어에는 제네릭이 있습니다.
제네릭은 함수가 값을 매개변수화하는 것처럼 타입을 매개변수화합니다.
따라서 제네릭은 개념적으로 JavaScript의 함수와 유사합니다.

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는 타입 매개변수로 제네릭을 허용하지 않습니다.
따라서 다른 함수에 함수를 전달하는 것처럼 제네릭을 다른 제네릭에 전달할 수 없습니다.

맵과 필터 메서드

타입 언어에서 타입은 변경할 수 없습니다. (immutable)
타입의 일부를 수정하려면 기존 타입을 새 타입으로 변환해야 합니다.
타입 언어에서 데이터 구조(즉, 개체 타입)를 반복하고 변환을 균일하게 적용하는 세부 사항은 Mapped Types에 의해 추상화됩니다.
이를 사용하여 JavaScript의 맵 및 필터 배열 메서드와 개념적으로 유사한 작업을 구현할 수 있습니다.
  • map 함수는 mapped types와 개념적으로 유사합니다.
  • filter는 mapped types + as의 조합으로 구현 가능합니다.
 
JavaScript에서 객체의 속성을 숫자에서 문자열로 변환하고 싶다고 가정해 보겠습니다.
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'}
타입 언어에서 이는 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 }
TypeScript에는 타입을 변환하기 위한 여러 내장 utility “functions”(제네릭)가 있으므로 바퀴를 재발명할 필요가 없는 경우가 많습니다.

패턴 매칭

또한 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])
}
타입 언어에서 위와 같은 동등성을 어떻게 작성할까요?
  1. FillArray라는 제네릭 타입을 생성합니다.(타입 언어의 제네릭이 함수와 같다고 말한 것을 기억하시나요?)
    • FillArray<Item, N extends number, Array extends Item[] = []>
  2. "함수 본문" 내에서 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로 증가했습니다.

프로덕션 코드에서 타입 체조를 피하세요

때때로 타입 프로그래밍은 일반적인 응용 프로그램에서 필요로 하는 것보다 불필요하게 복잡해지는 경향이 있습니다.
이를 타입 체조라고 하며, 되도록이면 지양해야 합니다.

 

관심있다면 아래 게시물을 체크해 보세요

  1. simulating a Chinese chess (象棋)
  2. simulating a Tic Tac Toe game
  3. implementing arithmetic
 
다음과 같은 이유로 프로덕션 애플리케이션에 적합하지 않은 학문적인 연습에 가깝습니다.
 
  • 난해한 TypeScript 기능은 정말 이해하기 어렵습니다.
  • 엄청나게 길고 비밀스러운 컴파일러 오류 메시지로 인해 디버그하기 어렵습니다.
  • 컴파일 속도가 느립니다.
핵심 프로그래밍 기술을 연습하기 위한 Leetcode가 있는 것처럼 타입 프로그래밍 기술을 연습하기 위한 타입 챌린지가 있습니다.
type-challenges

마무리

이 게시물의 목적은 TypeScript내에 숨어있는 타입 언어를 소개하는 겁니다.
타입 프로그래밍은 TypeScript의 틈새 시장이며 충분히 논의되지 않는 주제입니다
저는 이 경향이 아무런 문제가 없다고 생각하지 않습니다.
타입 시스템은 JavaScript로 더 신뢰할 수 있는 웹 응용 프로그램을 작성하기 위한 수단입니다.
따라서 사람들은 자바스크립트나 다른 프로그래밍 언어와 마친가지로 타입 언어를 공부하는데 충분히 시간을 투자해야 합니다.
 

참고

타입 자체에 대한 좀 더 깊은 이해를 위해 참고할 블로그를 추천합니다.

https://blog.hjaem.info/2

 

『프로그래밍 언어 속 타입』(가제) 책 소개

안녕하세요. KAIST 전산학부 프로그래밍 언어 연구실의 홍재민입니다. 현재 류석영 교수님의 지도 아래 박사 과정을 진행 중입니다. KAIST 학부 『프로그래밍 언어』 과목의 조교로 활동 중이며,

blog.hjaem.info

 

 

 

반응형