enum 타입은 원래 자바스크립트에는 없는 타입이지만 자바스크립트의 값으로 사용할 수 있는 특이한 타입이다.
enum Level{
BRONZE,
SILVER,
GOLD
}
자바스크립트로 변환하면 아래와 같다.
var Level;
(function (Level) {
Level[Level["BRONZE"] = 0] = "BRONZE";
Level[Level["SILVER"] = 1] = "SILVER";
Level[Level["GOLD"] = 2] = "GOLD";
})(Level || (Level = {}));
해석해보면
Level 변수를 만들고
{}을 기본값으로 하는 매개변수가 Level인 익명함수를 즉시실행한다.
Level["BRONZE"] = 0 는 BRONZE라는 키에 0을 할당하는 코드이다.
이 코드는 0을 반환한다.
그래서 결국
Level[0] = "BRONZE" 가 된다.
결국 키와 값 양 방향으로 설정을 한다는 뜻이다.
위의 코드를 실행하면 아래와 같은 객체가 만들어진다.
var Level = {
"0": "BRONZE",
"1": "SILVER",
"2": "GOLD",
"BRONZE": 0,
"SILVER": 1,
"GOLD": 2
}
0대신 다른 값을 할당할 수 도 있다.
enum Level {
BRONZE = 3,
SILVER, // 여기는 4가 됨
GOLD = 10,
}
문자열도 할당 가능하다.
enum Level {
BRONZE = "hi",
SILVER = "hello",
GOLD // 에러
}
문자열 할당할 때는 값을 빼먹으면 에러가 발생한다.
enum 타입은 객체로 변환된다고 했다.
자바스크립트 코드를 보면 알겠지만 Level 이라는 객체가 만들어진다.
그러니, Level 타입의 속성은 Level객체의 속성과 같으므로 값으로 사용될 수 있다.
const a = Level.BRONZE; // 0
const b = Level[Level.BRONZE]; // BRONZE
enum[enum의 속성] 는 enum의 멤버 이름을 가져오는 방법이니 알아두면 좋다.
enum은 값이 생기는 타입이라 값과 타입이 혼용되어 사용되므로 주의깊게 봐야 한다.
enum Level {
BRONZE,
SILVER,
GOLD,
}
function whatsYourLevel(level: Level) { // Level 타입으로 사용됨
console.log(Level[level]); // Level 값(객체)로 사용됨
}
const myLevel = Level.GOLD; // 값으로 사용 됨.
whatsYourLevel(myLevel);
enum 타입은 브랜딩을 위해 사용하면 좋다.
enum Money {
WON,
DOLLAR,
}
interface Won {
type: Money.WON;
}
interface Dollar {
type: Money.DOLLAR;
}
function moneyWonOrDollar(param: Won | Dollar) {
if (param.type === Money.WON) {
param; // param: WON
} else {
param; // parma: DOLLAR
}
}
다만, 브랜드 속성으로 enum 멤버를 사용할 때, 같은 enum의 멤버여야 한다.
다른 enum 멤버끼리는 구분이되지 않는다.
enum Money {
WON,
}
enum Water {
LITER,
}
interface M {
type: Money.WON;
}
interface N {
type: Water.LITER;
}
function moneyOrLiter(param: M | N) {
if (param.type === Money.WON) {
param; // param: M
console.log("WON");
} else {
param; // param: N
console.log("LITER");
}
}
moneyOrLiter({ type: Money.WON }); // WON 출력
moneyOrLiter({ type: Water.LITER }); // WON 출력
함수 내에서는 타입이 분리가 되는것을 볼 수 있다.
다만, 함수 호출시 인자 Money.WON 과 Water.LITER 가 사실상 0으로 같다.
그래서 함수 내에서 param.type 이 0 이되고 Money.WON 이 0이라서 모두 if문에 걸린다.
else 박스로는 가지 않는다.
따라서 enum을 사용하여 브랜딩 기법을 구현하려면 같은 enum의 다른 속성으로 구분해야 한다.
enum 타입을 사용하되, 자바스크립트 코드가 생기지 않도록 할 수도 있다.
const enum Money {
WON,
DOLLAR,
}
Money.WON; // 0
Money.DOLLAR; // 1
Money 객체는 생기지 않는다.
다만 특이하게 enum타입의 속성은 숫자 값으로 변환된다.
객체가 없으므로 Money[Money.WON] 과 같이 사용은 불가능하다.
infer는 컨디셔널 타입과 함께 사용한다.
다음과 같은 상황에서 infer를 활용할 수 있다.
배열이 있을 때 해당 배열의 요소 타입을 얻고 싶은 상황이다.
type El<T> = T extends (infer E)[] ? E : never;
type Result1 = El<string[]>; // Result1: string
type Result2 = El<(number | boolean)[]>; // Result2: number | boolean
타입 추론을 맡기고 싶은 부분을 infer 타입 변수 로 표시하면 된다.
단, 컨디셔널 타입에서 타입 변수는 참 부분에서만 사용할 수 있다.
위의 코드에서 거짓인 never 부분에 E를 사용한 표현식을 작성하면 에러가 난다.
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type P = MyParameters<(a: string, b: number) => string>;
// P: [a: string, b: number]
type MyReturnType<T> = T extends (...args: any) => infer R ? R : any;
type R = MyReturnType<(a: string, b: number) => string>;
// R: string
type MyConstructorParameters<T> = T extends abstract new (
...args: infer P
) => any
? P
: never;
type CP = MyConstructorParameters<new (a: string, b: number) => {}>;
// CP: [a: string, b: number]
type MyInstanceType<T> = T extends abstract new (...args: any) => infer R
? R
: any;
type I = MyInstanceType<new (a: string, b: number) => {}>;
// I: {}
args의 타입을 튜플로 추론한 이유는 함수의 매개변수는 순서와 타입이 고정된 리스트이기 때문에 TypeScript는 이를 튜플로 표현하기 때문이다.
서로 다른 타입 변수를 여러 개 동시에 사용할 수도 있다.
type MyPR<T> = T extends (...args: infer P) => infer R ? [P, R] : never;
type PR = MyPR<(a: string, b: number) => string>;
같은 타입 변수를 여러 곳에서 동시에 사용할 수도 있다.
type Union<T> = T extends { a: infer U; b: infer U } ? U : never;
type Result1 = Union<{ a: 1 | 2; b: 2 | 3 }>;
type Intersection<T> = T extends {
a: (pa: infer U) => void;
b: (pb: infer U) => void;
}
? U
: never;
type Result2 = Intersection<{ a(pa: 1 | 2): void; b(pb: 2 | 3): void }>;
기본적으로 합집합인 유니언 타입이 되지만, 매개변수인 경우 반공변성 특징 때문에 교집합인 인터섹션이 된다.
그렇다면 매개변수와 다른 자리에서 동시에 같은 타입을 적용하면 어떻게 될까?
다음은 반환값과 매개변수에 똑같은 타입을 적용했을 때의 경우이다.
type ReturnAndParam<T> = T extends {
a: () => infer U;
b: (pb: infer U) => void;
}
? U
: never;
type Result3 = ReturnAndParam<{ a: () => 1 | 2; b(pb: 1 | 2 | 3): void }>;
// Result3: 1 | 2
type Result4 = ReturnAndParam<{ a: () => 1 | 2; b(pb: 2 | 3): void }>;
// Result4: never;
반환값의 타입이 매개변수 타입의 부분집합인 경우에는 반환값의 타입이 된다.
그 외의 경우에는 never가 된다.
위의 특징들을 이용해서 유니언을 인터섹션으로 바꾸는 유틸 타입을 만들어보자.
type UnionToIntersection<T> = (
T extends any ? (p: T) => void : never
) extends (p: infer U) => void
? U
: never;
type Result5 = UnionToIntersection<{ a: number } | { b: string }>;
T 제네릭 타입에 타입 인수로 유니언 타입이 전달되었으니 분배 법칙이 일어난다.
근데 UnionToIntersection<{a: number}> | UnionToIntersection<{b: string}> 이 된다고 보면 틀렸다
왜냐하면 Result5 의 결과는 type Result5 = { a: number; } & { b: string; }
인데 위의 식대로 풀면 type Result5 = { a: number; } | { b: string; }
이 되기 때문이다.
type UnionToIntersection<T> = (
T extends any ? (p: T) => void : never // Step 1: 분배
) extends (p: infer U) => void // Step 2: 합쳐진 결과에 대해 실행
? U
: never;
// 이게 아니라:
UnionToIntersection<{a:number}> | UnionToIntersection<{b:string}>
// 이렇게:
((p: {a:number}) => void | (p: {b:string}) => void)
extends (p: infer U) => void ? U : never
그러니 extends가 여러개일때 주의해야 한다.
extends의 조건식에 따라 결과값이 먼저 도출되고 그 이후에 extends가 실행된다면
두 번째 extends의 좌변에서 유니언으로 다시 합쳐진다.
완전히 분리되어서 마지막에 합쳐진다고 생각하면 안된다.
만약 참과 거짓의 위치에 extends문이 껴있다면 다르다.
추가로 UnionToIntersection<boolean | true> 하면 뭐가 나올까?
boolean 과 true의 교집합으로 true가 나올까? 아니다.
앞서 배웠듯이 boolean은 true | false 이므로 위의 식은 UnionToIntersection<true | false | true> 의 교집합을 의미한다.
그래서 never가 된다.
typeof 연산자
function func(param: string | number) {
if (typeof param === "string") {
param; // string
} else {
param; // number
}
}
null과 undefined
typeof null은 object이다. 유명한 버그다.
그렇다고 if(변수) 처럼 truthy와 falsy값으로 분리를 하면 falsy값에 공백문자와 숫자 0 도 가능하므로 정확히 분리가 안된다.
걍 null과 undefined의 값은 하나니까 직접 비교해서 거르면 된다.
function func(param: string | undefined | null) {
if (param === null) {
param; // null
} else if (param === undefined) {
param; // undefined
} else {
param; // string
}
}
타입의 전체 집합이 boolean이라면 앞서 언급한 if(변수)를 사용한다.
falsy값으로 false만 있기 때문이다.
function func(param: boolean) {
if (param) {
param; // true
} else {
param; // false
}
}
배열을 구분할때는 Array.isArray를 쓴다
function func(param: string | number[] | [boolean]) {
if (Array.isArray(param)) {
param; // number[] | [boolean]
} else {
param; // string
}
}
튜플고 배열 개념은 타입스크립트에서만 제공되는 개념이므로 자바스크립트 문법으로는 분리할 수 없다.
타입스크립트가 제공하는 타입 서술 함수(Type Predicate)로 구분하자.
타입 서술 함수는 뒤에서 배운다.
클래스를 구분할 때는 instanceof를 쓴다.
A와 B가 클래스라서 instanceof가 먹힌다. 클래스는 타입이면서도 값이기 때문이다.
만약 인터페이스로 A와 B를 정의했다면 instanceof A는 당연히 에러다.
class A {}
class B {}
function func(param: A | B) {
if (param instanceof A) {
param; // A
} else {
param; // B
}
}
두 객체를 구분할때는 속성으로 구분한다.
단, 객체.속성 으로 접근하면 에러가 난다.
속성값이 객체 안에 있는지 없는지도 모르는데 접근하기 때문이다.
in 연산자로 해당 속성이 객체 내에 있는지 확인하는 방식을 사용한다.
interface X {
width: number;
}
interface Y {
length: number;
}
function func(param: X | Y) {
if ("width" in param) {
param; // X
} else {
param; // Y
}
}
이전에 배운 브랜드 속성을 사용해도 된다.
interface X {
_type: "X";
width: number;
}
interface Y {
_type: "Y";
length: number;
}
function func(param: X | Y) {
if (param._type === "X") {
param; // X
} else {
param; // Y
}
}
enum Type {
X,
Y,
}
interface X {
_type: Type.X;
width: number;
}
interface Y {
_type: Type.Y;
length: number;
}
function func(param: X | Y) {
if (param._type === Type.X) {
param; // X
} else {
param; // Y
}
}
잠깐 언급한 타입 서술 함수를 사용해서 직접 타입 좁히기 함수를 만들어보자.
enum Type {
X,
Y,
}
interface X {
_type: Type.X;
width: number;
}
interface Y {
_type: Type.Y;
length: number;
}
function check(param: X | Y) {
if (param._type === Type.X) {
return true;
} else {
return false;
}
}
function func(param: X | Y) {
if (check(param)) {
param; // X 가 아니라 X | Y
} else {
param; // Y 가 아니라 X | Y
}
}
분명 check함수에서 true를 반환하면 param이 X타입인데 타입스크립트에서 추론하지 못한다.
함수의 결과에 따라 타입의 분기를 결정하지 못한다.
이를 위해 특수한 작업을 해줘야 한다.
enum Type {
X,
Y,
}
interface X {
_type: Type.X;
width: number;
}
interface Y {
_type: Type.Y;
length: number;
}
function check(param: X | Y): param is X {
if (param._type === Type.X) {
return true;
} else {
return false;
}
}
function func(param: X | Y) {
if (check(param)) {
param; // X 가 아니라 X | Y
} else {
param; // Y 가 아니라 X | Y
}
}
param is X를 반환타입에 설정했다.
매개변수 하나를 받아 boolean을 반환하는 함수를 의미한다.
이렇게 하면 반환값이 true일 때 is 뒤에 적은 타입으로 좁혀진다.
헷갈리지말자. "반환값이 true일 때" 이다.
type Recursive = {
name: string;
children: Recursive[];
};
const recur1: Recursive = {
name: "test",
children: [],
};
const recur2: Recursive = {
name: "test",
children: [recur1, recur1],
};
컨디셔널 타입에도 사용할 수 있다.
type Recursive<T> = T extends (infer U)[] ? Recursive<U> : T;
위의 코드는 배열의 껍질을 벗겨내다가 최종적인 아이템의 타입을 찾는 코드이다.
재귀타입을 사용할 때 시점을 잘 생각해야 한다.
type T = Record<string, T>; // 에러
type T = { [key: string]: T }; // ok
위의 현상은 TypeScript 컴파일러가 타입을 해석(Evaluation)하는 시점의 차이 때문에 발생한다.
간단히 요약하면:
Record<string, T> (에러): "지금 당장 T를 풀어서 계산해!" (성급한 평가)
{ [key: string]: T } (성공): "일단 T라는 객체 틀을 만들고, 속성은 나중에 확인해." (지연된 평가)
자세한 이유를 비유와 기술적인 원리로 설명해보자.
❌ Case 1: type T = Record<string, T>;
Record는 TypeScript가 미리 만들어둔 '유틸리티 타입(제네릭 타입 별칭)'이다.
TypeScript에서 type 키워드로 정의된 제네릭 별칭(Record)을 만나면, 컴파일러는 즉시 이 타입을 전개(Expand/Resolve)하려고 시도한다.
컴파일러의 생각:
T를 정의해야지.
정의를 보니 Record<string, T>네?
Record는 별칭이니까 이걸 풀어서 실제 구조로 바꿔야 해.
Record를 풀려면 인자로 들어온 T가 뭔지 알아야 하네?
어? 근데 T는 지금 정의 중이잖아? (아직 모름)
에러! "순환 참조(Circular Reference)가 발생"
✅ Case 2: type T = { [key: string]: T };
이것은 별칭을 거치지 않고 직접 객체의 구조(Object Literal Type)를 정의한 것이다.
TypeScript는 객체 리터럴({ ... })이나 인터페이스 내부에서 자기 자신(T)을 참조할 때는 "평가를 뒤로 미룬다(Defer)".
컴파일러의 생각:
T를 정의해야지.
정의를 보니 { ... } 형태의 객체구나. (일단 껍데기 확정)
그 안에 [key: string]: T 속성이 있네?
속성 값인 T는 나중에 이 속성에 접근할 때 확인하면 되니까, 지금은 'T가 들어간다'는 참조만 걸어두자.
성공!
재귀 타입을 사용하는 대표적인 예시로 JSON 타입이 있다.
// JSON 값은 문자열, 숫자, 불리언일 수도 있고...
// "또 다른 JSON 객체"나 "JSON 배열"일 수도 있다.
type JSONValue =
| string
| number
| boolean
| { [key: string]: JSONValue } // <-- 여기서 자기 자신(JSONValue)을 다시 부름! (재귀)
| JSONValue[]; // <-- 여기도!
const data: JSONValue = {
name: "Gemini",
age: 1,
hobbies: ["coding", "learning"], // 배열 안에 문자열
config: { // 객체 안에 또 객체
theme: "dark",
settings: { // 객체 안에 객체 안에 또 객체...
notification: true
}
}
};
// 위 구조가 아무리 깊어져도 JSONValue 타입 하나로 모두 커버가 된다.
재귀를 사용하면 다음과 같이 응용도 가능하다.
type Reverse<T> = T extends [...infer L, infer R] ? [R, ...Reverse<L>] : [];
type Result = Reverse<[1, 2, 3]>; // Result: [3, 2, 1]
type Literal = "literal";
type Template = `template ${Literal}`;
const str: Template = "template literal";
문자열 타입 안에 다른 타입을 변수처럼 넣을 수 있다.
${타입} 에 들어갈 수 있는 타입은 string | number | bigint | boolean | null | undefined 이다.
const universe = {
sun: "star",
sriius: "star",
earth: { type: "planet", parent: "sun" },
};
// 추론된 타입
// universe: {
// sun: string;
// sriius: string;
// earth: {
// type: string;
// parent: string;
// };
// }
하지만 sriius가 오타가 났는데 위의 방식대로는 오타를 캐치할 수 없다.
그렇다고 직접 타입을 따로 작성하는건 비효율적이다.
인덱스 시그니처를 사용해보자.
const universe: {
[key in "sun" | "sirius" | "earth"]:
| string
| { type: string; parent: string };
} = {
sun: "star",
sriius: "star", // 에러
earth: { type: "planet", parent: "sun" },
};
당연히 오타를 잡는다.
하지만 earth 타입이 string | { type: string, parent: string} 이 되면서 earth의 속성값에 접근할 때 에러가 난다.
// 타입
const universe: {
sun: string | {
type: string;
parent: string;
};
sirius: string | {
type: string;
parent: string;
};
earth: string | {
type: string;
parent: string;
};
} = {...}
타입을 보면 완전 틀리다는걸 확인할 수 있다.
타입 추론의 이점을 누리면서 오타까지 알려줄 방법이 없을까?
이때 satisfies를 사용한다.
const universe = {
sun: "star",
sirius: "star",
earth: { type: "planet", parent: "sun" },
} satisfies {
[key in "sun" | "sirius" | "earth"]:
| { type: string; parent: string }
| string;
};
객체 리터럴 값 뒤에 satisfies를 사용하면 된다.
동작 순서는 satisfies의 앞에 나온 객체 리터럴의 타입을 추론하고, 그 후에 satisfies 뒤에 나온 타입에 대입시켜봐서 통과되는지 안되는지 확인하는 방식이다
타입스크립트에서 자주하는 실수가 있다.
타입을 강제로 주장하는 경우에 흔히 나온다.
const a = 1;
a as unknown;
a; // a: 1
a를 unknown으로 타입 주장을 했는 데 a의 타입은 리터럴 1 타입이다.
이는 원본을 그대로 두기 때문이다.
타입 주장을 할 꺼면 타입 주장한 결과값을 다른 변수에다가 대입해야한다.
다음과 같은 코드형식에서 실수를 하니 주의하자.
try {} catch(error){
if(error as Error){
error.message; // 에러
}
}
try {} catch(error){
const err = error as Error;
if(err){
err.message; // ok
}
}
try {} catch(error){
if(error instanceof Error){
error.message; // ok
}
}
이번에 따끈따끈하게 Error.isError(error) 문법이 나왔으니 보편화되면 아마 타입스크립트에서 해당 방식으로도 타입 네로잉이 될 것이라 기대해본다.
이전에 집합 관계를 설명할 때 number & { a: 1 } 가 왜 never가 아닌지에 대해서 브랜딩 기법을 위함이라고 설명했던 적이있다.
만약 3 이라는 숫자가 주어지면 이건 km일까 mile일까?
알 수 없다.
다음 코드를 보자.
type Km = number & { _type: "km" };
type Mile = number & { _type: "mile" };
function kmToMile(km: Km) {
return (km * 0.62) as Mile;
}
number 타입이 Mile 타입의 하위 집합이 아니므로 강제 변환이 필요하다.
하지만 한 번 변환하면 그 이후엔 Mile타입이 된다.
_type 속성을 직접 사용하지는 않지만 이렇게 같은 타입의 값이라도 메타 정보를 통해 더욱 정밀히 분류할 수 있다.
IsNever
type IsNever<T> = [T] extends [never] ? true : false;
IsAny
type IsAny<T> = string extends number & T ? true : false;
any는 파생되는 타입도 any가 된다.
위의 조건식을 만족하는 T는 any뿐이다.
IsArray
Array타입을 체크하기전 다음의 내용을 숙지하자.
type IsArray<T> = T extends unknown[] ? true : false;
type Result1 = IsArray<any>; // boolean
type Result2 = IsArray<readonly number[]>; // false;
type Result3 = IsArray<never>; // never
제네릭에 any를 넣으니까 true도 되고 false도 되어서 boolean타입이 되버린다.
readonly 를 넣은 배열은 배열로 처리되지 않는다.
never는 앞서 배웠다시피 never가 된다.
이 3개의 경우를 먼저 필터를 한 후에 배열 검사를 해야한다.
type IsArray<T> = IsNever<T> extends true // never 검사
? false
: IsAny<T> extends true // any 검사
? false
: T extends readonly unknown[] // readonly 검사
? true
: false;
참고로 위의 IsArray은 튜플도 true다.
IsTuple
튜플 타입을 검사하기 전에 이전에 튜플을 다음과 같이 정의내렸었다.
"각 요소의 자리에 타입이 정해진 배열"
그래서 [number, string, boolean] 도 튜플이고
[number, string, ...boolean[]] 도 튜플이라고 했다.
하지만 다음에 나올 튜플은 [number, string, boolean] 처럼 길이가 고정된 튜플로 한정한다.
왜냐하면 tuple과 array의 차이점은 length 속성의 타입인데, 길이가 고정된 튜플에 length 타입은 숫자 리터럴인 반면에 array는 number 타입이다.
길이가 고정되지 않은 튜플은 array와 같이 number타입이다.
가변길이의 튜플은 배열과 따로 구분하기 힘들다
type IsTuple<T> = IsNever<T> extends true // never 검사
? false
: T extends readonly unknown[] // readonly 검사
? number extends T["length"] // length 속성이 number면 배열
? false
: true // 튜플
: false;
any타입을 따로 필터하지 않은 이유는 any일 경우 number extends any['length'] 가 true가 되어 false를 반환하므로 같이 필터링 되기 때문이다.
IsUnion
type IsUnion<T, U = T> = IsNever<T> extends true
? false
: T extends T
? [U] extends [T]
? false
: true
: false;
U는 T의 스냅샷이다.
T extends T는 분배법칙을 유도하는 식이다.
만약 T에 string | number를 전달했다면 T에서 분배법칙이 일어난다.
[U] extends [T] 가 [string | number] extend [string]과 [string | number] extend [number] 로 분배된다.
두 식 판단 결과는 모두 false이므로 false 자리인 true가 반환된다.
만약 T에 string처럼 단일 타입이 전달되었다면?
[U] extends [T] 가 true로 판단되어 true 자리인 false가 반환된다.
차집합
type Diff<A, B> = Omit<A & B, keyof B>;
type R1 = Diff<
{ name: string; age: number },
{ name: string; married: boolean }
>; // R1: {age: number}
Omit 타입은 빌트인 타입으로, 특정 객체에서 지정한 속성을 제거하는 타입이다.
또한, 다시 한 번 말하지만, 객체의 속성 입장에서 A & B 는 합집합이다.
대칭차집합
type SymDiff<A, B> = Omit<A & B, keyof (A | B)>;
type R2 = SymDiff<
{ name: string; age: number },
{ name: string; married: boolean }
>; // R2: {age: number, married: boolean}
keyof(A | B) 는 keyof A & keyof B이다. 즉, "name" | "age" & "name" | "married" 이므로 "name"이 해당된다.
하지만 위의 대칭차집합은 객체 관계에서만 적용된다.
유니언에서는 적용되지 않는다.
type SymDiffUnion<A, B> = Exclude<A | B, A & B>;
type R3 = SymDiffUnion<1 | 2 | 3, 2 | 3 | 4>; // R3: 1 | 4
Exclude<A,B> 타입은 A타입에서 B타입을 제거하는 타입이다.
Equal
type Equal<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
근데 위의 방식에는 any가 대입될 시 맛이간다.
any를 포함한 Equal를 만들기위해서는 많이 복잡해진다.
any를 쓰지마 걍.
그래도 코드를 본다면
type Equal2<A, B> = (<T>() => T extends A ? 1 : 2)
extends (<T>() => T extends B ? 1 : 2)
? true
: false;
그렇다고 한다. 이해를 못하겠다.
Not Equal
type NotEqual<A, B> = Equal<A, B> extends true ? false : true;
타입스크립트의 에러 메시지 끝에는 항상 숫자가 있다.
"TS{숫자}" 로 구글에 검색하면 에러에 대한 해결방법이 나온다.
타입스크립트 5.0에 데코레이터 함수가 추가되었다.
class A {
eat() {
console.log("start");
console.log("eat");
console.log("end");
}
work() {
console.log("start");
console.log("work");
console.log("end");
}
sleep() {
console.log("start");
console.log("sleep");
console.log("end");
}
}
start와 end가 중복된다.
이를 데코레이터 함수를 이용해서 중복을 제거하자
function startEnd(originalMethod: any, context: any) {
function replaceMethord(this: any, ...args: any[]) {
console.log("start");
const result = originalMethod.call(this, ...args);
console.log("end");
return result;
}
return replaceMethord;
}
class A {
@startEnd
eat() {
console.log("eat");
}
@startEnd
work() {
console.log("work");
}
@startEnd
sleep() {
console.log("sleep");
}
}
데코레이터 함수의 매개변수인 originalMethod에 eat, work, sleep 메서드가 전달된다.
이 메서드들이 replaceMethod로 대체된다고 생각하면 된다.
매개변수 타입을 any로 작성했는데 이는 데코레이터의 전체적인 문법을 보여주기 위해 의도적으로 작성한 것이다.
아래의 코드는 매개변수를 타이핑한 결과이다.
function startEnd<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Return
>,
) {
function replaceMethord(this: This, ...args: Args): Return {
console.log("start");
const result = originalMethod.call(this, ...args);
console.log("end");
return result;
}
return replaceMethord;
}
기존 메서드의 this, 매개변수, 반환값을 각각 This, Args, Return 타입 매개변수로 선언했다.
context는 데코레이터의 정보를 갖고있는 매개변수이다.
위의 데코레이터는 메서드를 장식하고 있으므로 context는 ClassMethodDecoratorContext가 된다.
context의 종류는 다음과 같다.
context 객체의 타입은 다음과 같다..
type Context = {
kind: string; // 데코레이터 유형 "class" | "method"
name: string | symbol; // 대상 이름
access: {
get?(): unknown;
set?(value: unknown): void;
has?(valid: unknown): boolean;
};
private?: boolean;
static?: boolean;
addInitializer?(initializer: () => void): void;
};
데코레이터도 함수이므로 인자를 전달할 수 있다.
다만, 고차함수를 활용한다.
이런 기법은 자바스크립트에서도 많이 쓰이니까 뭐 특별한거 없다.
function WrapStartEnd(start: string = "start", end: string = "end") {
return function startEnd<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Return
>,
) {
function replaceMethord(this: This, ...args: Args): Return {
console.log(start); // 여기를 매개 변수로 설정
const result = originalMethod.call(this, ...args);
console.log(end); // 여기를 매개 변수로 설정
return result;
}
return replaceMethord;
};
}
class A {
@startEnd
eat() {
console.log("eat");
}
@startEnd("시작")
work() {
console.log("work");
}
@startEnd("시작", "끝")
sleep() {
console.log("sleep");
}
}
몇 가지 더 알아보자.
function log<Input extends new (...args: any[]) => any>(
value: Input,
context: ClassDecoratorContext,
) {
if (context.kind === "class") {
return class extends value {
constructor(...args: any[]) {
super(args);
}
log(msg: string): void {
console.log(msg);
}
};
}
return value;
}
function bound(
originalMethod: unknown,
context: ClassMethodDecoratorContext<any>,
) {
const methodName = context.name;
if (context.kind === "method") {
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this);
});
}
}
@log
export class C {
@bound
@startEnd() // 데코레이터 여러개를 사용할 수 있다.
eat() {
console.log("eat");
}
@bound @startEnd() work() { // 한줄에 선언할 수 있다.
console.log("work");
}
@startEnd("시작", "끝")
sleep() {
console.log("sleep");
}
}
context에는 addInitializer라는 메서드도 있다.
addInitializer 함수의 인자로 전달한 함수는 클래스의 인스턴스를 생성할 때(초기화)에 호출된다.
즉, new C()를 호출하면 this.eat = this.eat.bind(this)가 호출된다.
log 데코레이터는 클래스 데코레이터이다.
첫 번째 매개변수가 클래스 타입이고 반환값도 장식 대상 클래스를 상속한 클래스이다.
클래스 데코레이터의 경우 올 수 있는 위치는 다음과 같다.
@log export class C {...}
export @log class C {...}
@log
export class C {...}
export 앞 / 뒤 어디든 상관없다.
단, 여러개의 데코레이터를 사용한다면 앞 / 뒤 동시에 사용하면 안된다.
자바스크립트 생태계에서는 남의 코드를 가져다 쓰는 경우가 많은데 타입스크립트도 마찬가지다.
만약 타입스크립트에서 남의 라이브러리를 사용할 때 그 라이브러리가 자바스크립트라면 직접 타이핑을 해야 한다.
그럴 때 사용하는 것이 앰비언트 선언이다.
declare namespace NS {
const v: string;
}
declare enum Enum {
ADMIN = 1,
}
declare function func(param: number): string;
declare const variable: number;
declare class C {
constructor(p1: string, p2: string);
}
new C(func(variable), NS.v);
코드에 구현부가 없다.
외부 파일에 실제 값이 존재한다고 믿기 때문이다.
만약 외부 파일에 값이 없다면 런타임 에러가 발생한다.
인터페이스와 타입 별칭도 declare로 선언할 수 있다.
그러나 인터페이스와 타입 별칭은 따로 declare로 선언하지 않아도 동일하게 동작하므로 굳이 declare를 붙일 이유가 없다.
같은 이름의 다른 선언과 병합될 수 있다.
다음은 병합 가능 여부를 나타내는 표이다.
| 병합 가능 여부 | 네임스페이스 | 클래스 | enum | 인터페이스 | 타입 별칭 | 함수 | 변수 |
|---|---|---|---|---|---|---|---|
| 네임스페이스 | O | O | O | O | O | O | O |
| 클래스 | O | X | X | O | X | O | X |
| enum | O | X | O | X | X | X | X |
| 인터페이스 | O | O | X | O | X | O | O |
| 타입 별칭 | O | X | X | X | X | O | O |
| 함수 | O | O | X | O | O | O | X |
| 변수 | O | X | X | O | O | X | X |
외우기 힘들고 매번 찾기도 어려우니 인터페이스, 네임스페이스 병합이나 함수 오버로딩과 같이 널리 알려진 병합을 제외하고는 같은 이름으로 여러 번 선언하지 않는 것이 좋다.
하지만 다음과 같은 경우에는 의도적으로 병합을 활용하면 좋다.
declare class A {
constructor(name: string);
}
function A(name: string) {
return new A(name);
}
new A("kdh");
A("kdh");
이미 A라는 클래스가 어딘가에 구현이 되어있고 해당 클래스의 인스턴스를 만들 때는 new A()를 호출해서 생성해야 하는 상황이라고 가정하자.
new A()뿐만 아니라 A()와 같이도 사용하고 싶을 수 있다.
그러기 위해 new A()를 리턴하는 A라는 이름의 함수를 작성했다.
하지만 A라는 이름은 클래스와 중복되므로 병합을 위해 클래스 A를 앰비언트 선언한 것이다.
또한, 다음과 같은 경우에도 앰비언트 선언 병합을 활용하면 좋다.
function EX() {
return "hello";
}
namespace EX {
export const a = "world";
export type B = number;
}
EX();
EX.a;
const b: EX.B = 123;
자바스크립트에서는 함수도 객체이므로 함수에 속성을 추가할 수 있다.
함수와 네임스페이스가 병합될 수 있으므로 에러가 발생하지 않는다.
함수에 속성이 별도로 있다는 걸 알리고 싶다면 함수와 동일한 이름의 namespace를 추가하면 된다.