[NAVER D2] infer, never만 보면 두려워지는 당신을 위한 고급 TypeScript

Episode 1: 들어가기에 앞서

여러분들은 타입스크립트를 얼마나 알고 있나요?

type Animal = (Dpg | Cat) & Commonanimal
type Animals<Animal> = Record<string, Animal[]>
type AnimalColors<Animals> = Animals extends [infer T extends Animals, ...infer Rest]
    ? [T['color'], ...AnimalColors<Rest>]
        : []
type ToIntersection<T> = (T extends any
    ? (_: T) => void
        : never
) extends (_: infer S) => void
    ? S
    : never

 

위 코드들에 대해서 얼마나 이해할 수 있는가?

위 두 개의 코드를 제외하고 이해가 잘 가지 않지만 이 강의가 끝날 때 쯤이면 전부 이해할 수 있기를 바란다.

Episode 2: 타입이론과 TypeScript

때는 바야흐로 1901년...

러셀의 역설(Russell's Paradox)의 발견

러셀의 역설 공식

자기 자신을 포함하지 않는 모든 집합의 집합 R을 상상하자.

만약 R이 R에 속한다면,

R은 자기자신에 속하지 않는 게 아니므로 모순이다.

만약 R이 R에 속하지 않는다면,

R은 자기자신에 속하지 않는 집합이므로 R에 속해야 한다.

그러니 모순이다.

그러므로 그런 집합은 존재할 수 없다.

수학기초론의 태동

  • 19세기 당시 수학계는 생각보다 엄밀하지 않았음
  • 모든 수학의 기초인 집합론이 흔들리면서 패닉
  • 20세기부터 수학적 엄밀함을 정의하려는 시도
    • ZF(C) 공리계, 괴델의 불완전성 정리, 람다대수 등 현대수학이 꽃피기 시작
    • 진공관의 발명과 맞물려 컴퓨터의 탄생까지 간접적으로 이어짐

컴퓨터가 탄생할 때 위의 역설이 영향을 미쳐, 타입이 탄생하게 되었다.

타입 이론의 탄생

  • 앨런조 처치(Alanzo Church)의 고민 - 함수란 무엇인가?
  • 람다대수(Lambda Calculus)를 고안
  • 연구하다보니 타입의 개념이 필요함을 깨달음
  • 이 외의 다양한 수학자들이 유사한 시도
  • 프로그래밍 언어라는 것이 존재하기도 전에 탄생
  • FYI, https://plato.stanford.edu/entries/type-theory/

이전까지는 함수란 집합과 집합의 관계로만 생각을 했었다.

람다대수의 이론적인 근거를 제안하게 된다.

이를 연구하다 보니 타입이 필요하다는 것을 깨달았다.

TypeScript와 타입 이론

  • TypeScript 1.8까지는 공식문서가 존재했으나, 이후 Microsoft가 문서 유지보수 포기
  • tsc의 구현이 곧 TypeScript의 스펙
  • 본 강의는 tsc를 블랙박스로 간주하고, 수학적 일관성과 실험을 근거로 서술
  • 저는 TypeScript 팀의 입장을 대변하지 않습니다.

Episode 3: 기초 타입 추론

각 형별 타입 추론 원리를 알아봅시다.

타입이란?

어떤 심볼(Symbol, =변수명)에 엮인(Binded)

메모리 공간에 존재할 수 있는

값(Value)의 집합과

그 값들이 가질 수 있는 성질(Properties)

ex)

3.141592 : number

3.41592는 타입 number속한다.

타입  { x: number; y?: string }  은 타입  { x: number }  의  서브타입  이다.

부등호의 의미는 타입이 서로 포함 관계라는 뜻이다.

오른쪽 타입이 더 크고 넓은 타입이라는 뜻이다.

타입 { x: number; y?: string } 은 타입 { x: number }서브타입 이다. (반대는 슈퍼 타입)

타입 B가 가지는 모든 속성을 A도 가지면, 타입 A는 B의 서브타입이다.

타입 B가 가지는 모든 속성을 A도 가지면, 타입 A는 B의 서브타입이다.

타입은 부분순서집합(Partially Ordered Set) 이다

  • 실수는 a > b 이거나 a = b 이거나 a < b

타입과 값의 대입

  • rval의 타입 ≼ _lval의 타입_일 때 lval := rval가 가능
const x: number = 42 // (OK)
const x: string = 42 // (X)
const x: string | number = 42 // (OK)
  • 타입 비교는 대입 상황이 발생할 때 수행
    • lval의 타입을 근거로 rval의 서브타입인지 확인

원시 타입 ( Primitive Type)

  • boolean, number, string, symbol, undefined, null
  • 공리적으로 정의
  • 이들 간에는 어떠한 관계도 없음 (서로 무관계)
  • null을 제외하곤, 값에 typeof를 수행했을 때 해당 타입 이름이 나옴

리터럴 타입(Literal Type)

  • 어떤 타입에 속한 값 하나만으로 구성하는 타입
  • 본래 타입의 서브 타입으로 간주
  • ex) 6, { srawberry: 'delicious' }
  • as const 로 특정 값을 리터럴 타입으로 선언할 수 있음
const x = 6 as const

 

원래 x 의 타입은 number 이지만, as const 를 붙이면 6 이라는 타입으로 정의된다.

객체도 마찬가지이다.

 

객체 타입 (Object Type)

  • lval의 타입 L, rval의 타입을 R이라 하자.
  • L의 모든 속성 P에 대하여 L[P] > R[P]이면 L > R ( 여기서 > 는 ≲를 거꾸로 한  것과 같다)
    • 이때 객체의 타입속성의 타입에 대해 공변적(Covariant)이라고 한다.
{
    x: number
    y?: string
    z: boolean
}
{
    x: number
    y: undefined
    z: false
    a: 'foo'
}

 

위는 L > P 의 관계이다.

= P 는 L 의 서브타입이다.

 

 

배열/튜플 타입 (Array/Tuple Type)

  • 객체와 동일하나, number를 키로 갖는다는 점이 다름
  • 튜플은 length가 상수 리터럴 타입으로 고정

배열과 튜플 타입의 상관관계

 

 

키 타입 (keyof)

  • 객체 타입의 속성의 합집합 (|) 으로 이루어진 타입
  • 가질 수 있는 가장 넓은 타입은 number, string, symbol
  • 명시적인 타입이 주어진 경우, 필드명의 union
type k = "x" | "y"
type k = keyof { x: number, y?: string }

 

위 x 타입의 슈퍼 타입은 아래와 같다.

 

keyof x < number | string | symbol;

 

함수 타입 (Function Type)

  • 반환형과 인자형의 대입 조건을 모두 만족해야 함
  • 반환형에 대해서는 공변적(Covariant)
  • 인자형에 대해서는 반변적(Contravariant)

 

함수 타입 (Function Type) - 반환형

A가 B의 서브타입이라면 인자형이 같을 때 A를 반환하는 함수가 B를 반환하는 함수의 서브타입이다.

 

A가 B의 서브타입이라면

인자형이 같을 때

A를 반환하는 함수가 B를 반환하는 함수의 서브타입이다.

 

  • 반환형에 대해서 공변적인 이유?
    • 반환값은 rvalue로 사용
    • f < g 라고 할 때, let lval = g(x)라면 lval = f(x)도 가능해야 하기 때문

 

함수 타입 (Function Type) - 인자형

A가 B의 서브타입이라면 반환형이 같을 때 B를 인자로 갖는 함수는 A를 인자로 갖는 함수의 서브타입이다.

 

A가 B의 서브타입이라면

반환형이 같을 때

B를 인자로 갖는 함수는

A를 인자로 갖는 함수의 서브타입이다.

 

  • 인자형에 대해서 반변적인 이유?
    • f < g 라고 하자.
    • g(x)가 유효한 대입이라면 f(x)도 유효해야 함
      • 구체적인 것(f)을 추상적인 것(g)에 대입했으므로
    • 그런데 f의 인자가 g의 인자보다 좁은 타입만 허용하면, f(x)가 유효하지 않을 수가 있음

예시)

function processNumber(x: number) {}
function processNumberAndString(x: number | string) {}
let wide: (x: number) => void;
wide = processNumber; // (O)
wide = processNumberAndString; // (O)

let narrow: (x: string | number) => void;
narrow = processNumber; // (X)
narrow = processNumberAndString; // (O)

 

아래와 같은 타입 에러가 발생한다.

Type '(x: number) => void' is not assignable to type '(x: string | number) => void'.
Types of parameters 'x' and 'x' are incompatible.
Type 'string | number' is not assignable to type 'number'
Type 'string' is not assignable to type 'number'.

 

 

  • 인자의 수가 불일치하는 경우
    • 인자가 적은 함수인자가 많은 함수 타입에 대입할 수 있음
    • 거꾸로는 안됨
  • 왜?
    • 그 함수를 사용하는 곳에서 "넣어줘야 할 인자를 안넣어주는 상황"을 방지하기 위함

예시)

function consumeOneArg(x: any) {}
let wide: (x: any, y: any) => void;
wide = consumeOneArg;
wide(0, 1); // consumeOneArg(0, 1), ignore 1

function consumeTwoArg(x: any, y: any) {}
let narrow: (x: any) => void;
narrow = consumeTwoArg;
narrow(0); // consumeTwoArg(0, ?)

 

아래와 같은 타입 에러가 발생한다.

Type '(x: any, y: any) => void' is not assignable to type '(x: any) => void'.
Target signature provides too few arguments. Expected 2 or more, but got 1.

 

 

특수 타입

  • never, unknown, any, void
    • void는 부록 참고
    • 여기서는 undefined하고 비슷하다고만 이해하시면 됩니다

 

never와 unknown

어떤 타입에 대해서도 never < T < unknown이 성립한다

 

어떤 타입에 대해서도 never < T < unknown이 성립한다.

 

  • never는 가장 좁은 타입
    • 값의 집합이 공집합
    • 타입 에러를 고의적으로 발생시킬 때에도 사용 (제네릭에서 다시 설명)
  • unknown은 가장 넓은 타입
    • any, unknown을 제외하고는 어떠한 곳에도 대입이 불가능함
const thisIsNever: never = 0; // (X)
const thisIsUnknown: unknown = 0; // (O)
const neverCantReceiveAnything: never = {} as unknown; // (X)
const unknownCanReceiveAnything: unknown = {} as unknown; // (O)
const unknownCantBeAssigned: number = 0 as unknown; // (X)

 

 

any

never를 제외한 모든 타입은 any와 서로 서브타입이다. 단, never는 any의 서브타입이며 그 역을 성립하지 않는다.

 

never를 제외한 모든 타입은 any와 서로 서브타입이다.

단, never는 any의 서브타입이며 그 역을 성립하지 않는다.

 

 

퀴즈

  • 다음 중 이론 상 가장 넓은 함수의 타입은?
    1. (...args: unknown[]) => unknown
    2. (...args: never[]) => unknown
    3. (...args: any[]) => any
    4. (...args: void[]) => never
더보기
정답 !!

2번????????????????????? 와 맞았다

해석 )

반환형은 공변성을 갖기 때문에 넓은 타입이 넓은 함수다.
인자는 반변성을 갖기 때문에 좁은 타입이 넓은 함수다.

참고로 2번은 존재할 수 있는 모든 함수들의 슈퍼 타입이다.

 

Episode 4: 고급 타입 추론

TypeScript 제네릭은 튜링완전어쩌구저쩌구...

 

타입 검사 ( Type Checking )

  • 어떤 전체/부분 심볼에 대한 대입, 연산, 참조가 가능한지 확인하는 과정
    • 모든 심볼이 제약 조건(=타입)을 만족하는가?
    • 어떤 코드 맥락에서, 심볼이 가질 수 있는 타입은 어떤 것이 있는가? ( ex. Type Guard가 된 if문 블록 )
  • 제약조건을 갖는 만족 가능성 문제(Constrained Satisfaction Problem, CSP)와 동치
    • tsx가 SAT solver 혹은 CSP solver를 구현하는 것은 아님
    • SAT 문제는 NP-완전: 이론상 지수시간복잡도가 발생
    • 그리고 제네릭을 가지고 놀다보면 종종 그런 케이스를 마주할 때도 있음 ( 이 경우 tsc가 오답을 도출 )
  • 완벽하게 풀기엔 문제의 크기가 너무 크다!
    • tsc는 Greedy 알고리즘을 사용하는 것으로 추정, 극도로 제한된 추론 깊이 ( ~100 )

 

const x: number = 'a';

 

위 코드를 타입스크립트 컴파일러는 어떻게 받아들일까?

 

string literal 'a' -> 'a': string

declare number const x -> : number

define x as rvalue

-> number > string 이라고 추론

하지만 실제로는 number 와 string 은 포함관계가 아님

contradiction 발생

 

defined x as rvalue 에서 문제가 발생했다는  것을 추론하여 빨간 줄 발생

 

제네릭 ( Generic )

  • 타입에 대한 함수이자 관계 그 자체
  • 1차 논리만 서술 가능
    • 즉 고차함수는 허용하지 않음
    • 예를 들면 제네릭의 제네릭 같은 거 ( ex: F<T> = T<number>

제네릭에 제네릭을 넣을 수는 없다.

 

 

명시적 타입 전달 ( Explicit Type Argument Passing )

  • 제네릭 타입 인자에 직접 타입을 기술 ( ex. useState<{ x?: number }>({}))
  • 전달한 정보를 전제로 사용
  • 컨트롤 플로우 상 제네릭이 선언된 심볼 다음부터는 전달한 대로 간주
  • 엔드유저들이 사용하는 주된 방식
let x: {x?: number}
x = identity<{ x?: number }>({ x: 'hi' })

 

declare { x?: number }  

type variable x

literal { x: 'hi' } ➡ { x: 'hi } : { x: string } ➡ { x?: number } > { x: string }

call identity <{ x?: number }> ➡ { x?: number } > typeof param0

number | undefined > string ?

contradiction ( 증명 실패 )

컴파일러 추론 단계 ( 증명 실패 )
컴파일러 추론 단계 ( 증명 성공 )

 

 

만약 제네릭을 적어주지 않으면 컴파일러가 타입을 어떻게 추론할까?

타입 인자 추론 ( Type Argument Inference )

  • 타입 선언 안 한 경우 / 제네릭 타입 인자 생략한 경우 / infer 문 사용한 경우
  • 컨트롤 플로우 분석으로 수집한 전제로, 해당 타입 인자를 추론
  • 최대한 비관적이고 보수적으로 분석 
    • ex. 만약 rval 타입이 string 이라고 가정해보자.
    • lval 타입에는 string 의 수퍼타입이 올 수 있다. string | undefined, any, ...
    • 이때 가장 좁은 타입을 lval 로 추론한다. ➡ string
  • Greedy
    • 완벽하게 추론이 불가능
  • 라이브러리 제작자가 자주 고려해야할 방식
    • 함수 시그니쳐 오버로딩에서 강력
    • 사용자들이 최대한 번거롭게 제네릭을 쓰지 않기 원함
    • 함수 시그니쳐 같은 경우에는 다른 시그니쳐도 여럿 쓸 수 있다.
    • 이때 타입 인자 추론에 대한 감각이 있어야 순서를 서술하는데 민감하게 다룰 수 있다.

예시 )

function f<T>(value: Promise<T>): T
function f<T>(value: T): T[]
function f<T>(value: number): string // f(3) 추론 시 무시
const x = f(3)

 

f(3) 호출 시 3번째 함수가 호출 되어야 하는 거 아니야? 라고 생각할 수 있다.

하지만 타입 인자 추론은 Greedy 하기 때문에 위부터 아래의 방향으로 추론하게 된다.

 

call f(3)

satisfies f<T>(value: Promise<T>): T ?

exists Promise<T> > number ? ➡ NO

satisfies f<T>(value: T): T[] ?

exists T > number ? ➡ YES

f(3): number[]

타입 인자 추론 단계

 

infer 에 대해서 자세히 알아보자.

조건부 타입 ( Conditional Type )

  • 어떤 타입이 다른 타입의 서브타입인지 확인
  • 그 여부에 따라서 다른 타입으로 변환
  • 생각보다 Greedy하고 일관성 없음 - 왜 안되는지 모를 때가 많음 (부록참고)
type IsNever<T> = [T] extends [never] ? true : false

 

extends 키워드나 infer 라는 키워드를 가질 수 있다.

이 조건이 만족이 되었을 때 true 나 false 를 반환한다.

 

갑자기 강의가 생략됨 ... ㅠㅠ

 

 

Episode 5: 응용 문제

라이브러리 개발자 체험해보기

function flattenObject(obj: any, result: any = {}): any {
	for (const key in obj) {
		if (
			typeof obj[key] === 'object' &&
			obj[key] &&
			!(obj[key] instanceof Array)
		) {
			flattenObject(obj[key], result);
		} else {
			result[key] = obj[key];
		}
	}
	return result;
}

함수 설명

 

위의 함수가 하는 것을 설명하는 사진이다.

타입을 어떻게 지정해야 할까?

 

중첩되지 않은 값과 중첩된 값이 있다.

맨 처음 해야하는 일은 그 둘을 분리해야 하는 것이다.

 

분리하는 타입을 SimpleFlattendObject 로 만들어보자.

FliterValue 에서는 중첩된 객체일 땐 never, 아니면 T 를 반환한다.

type SimpleFlattendObject<T extends object> = {
	[K in keyof T]: FilterValue<T[K]>;
};

type FilerValue<T> = T extends object ? (T extends unknown[] ? T : never) : T;

 

위의 코드를 타입으로 걸어보면 에러가 발생한다.

FilterValue 에러 메시지

 

왜?

value 에 never 를 걸어도 key 값이 존재하기 때문이다.

value 만 날리는 게 아니라 key 값도 같이 날려야 한다.

 

FilterPrimitiveKeys 타입을 만들어보자.

이때 이 타입에는 Key 뿐만 아니라 T 값도 필요하다.

K 를 대입할 모체 타입이 필요하기 때문이다.

type SimpleFalttendObject<T extends object> = {
	[K in FilterPrimitiveKeys<T, keyof T>]: T[K];
};

type FilterPrimitiveKeys<T, K> = K extends keyof T
	? T[K] extends object
		? T[K] extends unknown[]
			? K
			: never
		: K
	: never;

 

반대로 중첩된 객체 타입만 반환하는 타입도 만들어보자.

type NestedObject<T extends object> = {
	[K in FilterNestedKeys<T, keyof T>]: T[K];
};

type FilterNestedKeys<T, K> = K extends keyof T
	? T[K] extends object
		? T[K] extends unknown[]
			? never
			: K
		: never
	: never;

 

중첩된 객체의 depth 를 들어내는 타입은 어떻게 만들까?

중첩된 객체 depth 들어내기

type Values<T extends object> = T[keyof T];

/*

T['x' | 'y' | 'a' | 'b' | 'h']
=> T['x'] | T['y'] | ...
=> number | { z: string } | ...

*/

 

이것을 그대로 사용하는 것이 아닌 중첩된 객체들만 가져다 사용해보자.

중첩된 객체만 depth 들어내기

type UnwrappedObject<T extends object> = Values<NestedObject<T>>;

 

좀 더 예쁘게 타입이 정리 되었다.

이 타입들이 union이 아닌 인터섹션으로 바뀌기만 하면 모든 게 해결될 것 같다.

 

이걸 어떻게 하면 바꿀 수 있을까?

stackoverflow 에 코드가 있다.

type ToIntersection<T> = (T extends any ? (_: T) => void : never) extends (
	_: infer S,
) => void
	? S
	: never;

 

차근차근 읽어보자.

................어렵다

 

T ==> T extends any ? 항상 만족 하는데 왜 조건을 걸었을까?

 

우리의 궁극적인 목적은 union 형식을 intersection으로 바꾸는 것이다.

위에서 number | string 이런 형태의 union이 있었는데 해당 조건부를 걸지 않고 바로 (_:T) => void 형태로 가게 된다면

(_: stirng | number) => void 로 추론이 된다. (추론의 의미가 없다.)

 

만약 조건을 걸게 된다면 distributive law 라는 분배 법칙이 적용되기 때문에 개별로 따로 따로 조건이 적용된다.

((_: number) => void | ((_: string) => void)

 

그렇기 때문에 항상 만족하는 조건을 넣은 것이다.

 

 위에서 걸러진 ((_: number) => void | ((_: string) => void) 타입이 (_: infer S) => void 의 서브타입인지 아닌지 체크를 한다.

서브타입이면 S 를 반환한다.

 

여기서 깊게 궁금한 사람이 있다면 수학과에 가서 집합론을 들어야 한다...🥹

 

함수의 인자는 반변적이기 때문에 number와 string 을 둘다 만족시킬 수 있는 인자 타입이어야 한다.

그렇기 때문에 S 가 number 와 string 보다 작아져야 한다.

 

number & string ==> infer S

 

type X = UnwrappedObject<{
	x: number,
    y: {
    	z: string,
    },
    a: null,
    b: [1],
    h: {
    	hh: 'hh',
    }
}>

type Y = ToIntersection<X>

유니언을 인터섹션

 

유니언이 앤드로 바뀌었다 !!

 

type UnwrappedObject<T extends object> = ToIntersection<Values<NestedObject<T>>>

 

위 코드의 결과값

 

최종적인 코드

type FlattendObject<T extends object> = SimpleFlattendObject<T> &
	UnwrappedObject<T>;

최종 결과값

 

하지만 위의 타입은 디버깅하기 어렵다.

type Roll<T> = {
	[K in keyof T]: T[K]
} & {}
type X = Roll<FlattendObject<{
	x: number,
    y: {
    	z: string,
    },
    a: null,
    b: [1],
    h: {
    	hh: 'hh',
    }
}>>

 

Roll 타입을 붙이면 추론을 해준다.

 

Roll 타입을 추가하면 자동으로 추론을 해준다.

 

하지만 좀 더 depth 가 깊어지면 flatten 이 되지 않는다.

depth 가 깊어졌을 때 flatten 되지 않음

 

이걸 풀기 위해서는 재귀를 사용해야 한다.

 

이때 위에서 했던 UnwrappedObject 타입을 다시 보자.

UnwrappedObject

이때는 depth 를 풀기 전 객체로 전부 엮여있던 상황이다.

인터섹션을 가기 전에 Unwrapped 에서 다 풀어버리면 되는 거 아닐까?

 

intersection 처리를 하기 전에 {} | {} | {} 객체로 처리가 되었었다.

toIntersection 을 거치고 나면 {} & {} & {} 형식으로 바뀌었다.

이때 객체 안에 또 다른 객체가 들어있을 수 있다.

 

FlattendObject 과정에서 객체를 전부 풀어보면 어떨까?

Intersection 전에 Flattend 처리

이렇게 하면 무한 루프가 발생한다.

 

여기에 삼항 연산자라를 넣어볼까? (=지연 평가)

타입스크립트 제네릭 또한 컴파일러가 인지 ➡ 평가를 해야 하는 구조

만나자마자 평가 or 지연 시켜서 컴파일러가 돌아가는 런타임 중에 평가를 하는 2가지 방법이 있다. 

 

2번째 방법 중 대표적인 방법

제네릭 타입에서 conditioned type 을 사용하는 것.

➡ 지연 평가가 이루어진다.

type RecursionHelper<T> = T extends object ? 
	? FlattendObject<T>
	: never;

 

제네릭에 object 타입만 넣어줄 것이기 때문에 T extends obejct 조건이 필요한 건 아니지만 지연 평가를 위해 넣는 것이다.

무한루프에서 지연평가 추가

 

모든 객체들이 Flatten 된 것을 확인할 수 있다.

 

자, 이제 다 끝났다.

Roll<FlttendObject> 타입을 함수에 갖다 붙이면 된다. 

function flattenObject<T extends object>(obj: T, result: any = {}): Roll<FlattendObject<T>> {
	for (const key in obj) {
		if (
			typeof obj[key] === 'object' &&
			obj[key] &&
			!(obj[key] instanceof Array)
		) {
			flattenObject(obj[key], result);
		} else {
			result[key] = obj[key];
		}
	}
	return result;
}

 

위와 같이 타입을 변경하면, 함수 내부에서 타입 에러가 발생한다. (TypeScript 가 완벽하지 않다는 증거)

typeof obj[key] === 'object' 라는 조건을 거침에도 불구하고 flattenObject(obj[key], result) 에서 obj[key] 의 타입이 맞지 않다는 에러가 발생한다.

 

강사분은 @ts-ignore 를 사용했지만, 더 나은 방법이 있다면 좋을 것 같다.

 

flattenObject 함수 결과
flattenObject 함수 결과 ( 값을 더 복잡하게 변경 )

 

기적적으로 모든 타입들이 추론되는 것을 볼 수 있다.

 

지금까지 나온 코드 정리

전체 코드 요약

 

 

🤔 이 정도로 정밀하게 타입 추론을 해야 할까?

라이브러리 개발자 or 사내 니즈에 따라 하면 된다.

허들이 있기 때문에 가독성이 좋진 않지만 안정적으로 사용할 수 있다.

양날의 검이기 때문에 선택해서 사용하면 될 것 같다.

'TypeScript' 카테고리의 다른 글

[TypeScript] 기본 타입  (0) 2022.09.13
[TypeScript] TypeScript 시작하기  (0) 2022.09.06