1. 타입스크립트 이해하기
타입스크립트를 이해한다는 말은 타입스크립트가 어떤 기준으로 타입을 정의하고, 어떤 기준으로 타입들간의 관계를 정의하고, 어떤 기준으로 타입스크립트 코드의 오류를 검사 하는지 그 원리와 동작 방식을 낯낯이 살펴본다는 말이다.
중요한 문법 들만 쏙쏙 뽑아서 달달 외우듯이 빨리 배워서 빨리 프로젝트에 타입스크립트를 적용하는것도 방법이 될 수 있긴 하지만 타입스크립트는 문법만 외워서 대부분의 상황에 잘 써먹을 수 있을 만큼 만만한 언어는 아니다. 원리와 개념에 대한 수준급의 이해가 없다면 새롭거나 어려운 문제를 맞닥뜨렸을때 스스로의 힘으로 해결하기 힘들다.
결론은 뭐다? 뭘 배우던 시간을 가지고 천천히 배워나가야 한다.
급할 때 볼 수 있는 타입스크립트 중요 문법 모음도 있다.
https://www.typescriptlang.org/cheatsheets
2. 타입은 집합이다
타입스크립트의 '타입'은 사실 여러개의 값을 포함하는 '집합'이다. 집합은 동일한 속성을 갖는 여러개의 요소들을 하나의 그룹으로 묶은 단위를 말한다. 따라서 다음 그림처럼 여러개의 숫자 값들을 묶어 놓은 집합을 타입스크립트에서는 number 타입이라고 부른다.
20 이라는 Number Literal 타입이 존재한다면, 이 타입은 다음 그림과 같이 딱 하나의 값만 포함하는 아주 작은 집합이라고 볼 수 있다.
그리고 이 20 이라는 타입에 속하는 요소인 숫자 20은 이 사실 집합 외에도 Number 타입이라는 거대한 집합에도 속하는 값이다. 20도 결국 숫자이기 때문이다. 그러므로 결국 모든 Number Literal 타입은 Number 타입이라는 거대한 집합에 포함되는 부분 집합으로 볼 수 있다.
사실 타입스크립트의 모든 타입들은 집합으로써 서로 포함하고 또 포함되는 이런 관계를 갖는다. 그리고 이런 관계에서 Number 타입처럼 다른 타입을 포함하는 타입을 슈퍼 타입(부모 타입)이라고 부른다. 반대는 서브 타입(자식 타입)이라고 한다.
# 타입 호환성
타입 호환성이란 A와 B 두개의 타입이 존재할 때 A 타입의 값을 B 타입으로 취급해도 괜찮은지 판단하는 것을 의미한다. 그래서 만약 A 타입의 값이 B 타입의 값으로 취급 되어도 괜찮다면 호환된다고 하고, 안된다면 호환되지 않는다고 한다.
이건 마치 정사각형과 직사각형의 관계와 비슷하다. 정사각형을 직사각형으로 취급하는건 괜찮지만 반대로 직사각형을 정사각형으로 취급하는건 안되는 것 처럼 더 작은 타입의 값을 더 큰 타입의 값으로 취급하는 것은(ex : 정사각형은 직사각형이다) 상관없지만 반대(ex : 직사각형은 정사각형이다)로는 안되는 것이다.
타입스크립트에서는 이렇게 슈퍼타입의 값을 서브타입의 값으로 취급하는것을 허용하지 않는다. 반대로는 허용한다.
그리고 특별히 서브 타입의 값을 슈퍼 타입의 값으로 취급하는 것은 업 캐스팅 이라고 부르고 반대는 다운캐스팅이라고 부른다. 쉽게 정리하면 업캐스팅은 모든 상황에 가능하지만 다운 캐스팅은 대부분의 상황에 불가능하다고 할 수 있다.
3. 타입 계층도와 함께 기본 타입 살펴보기
1) unknown 타입 (전체 집합)
unknown 타입은 타입 계층도의 최 상단에 위치한다.
let a: unknown = 1; // number -> unknown
let b: unknown = "hello"; // string -> unknown
let c: unknown = true; // boolean -> unknown
let d: unknown = null; // null -> unknown
let e: unknown = undefined; // undefined -> unknown
let f: unknown = []; // Array -> unknown
let g: unknown = {}; // Object -> unknown
let h: unknown = () => {}; // Function -> unknown
unknown 타입이 타입 계층도에서 가장 위에 위치한다는 뜻은 unknown 타입은 모든 타입의 슈퍼타입이라는 뜻 이다. 그러므로 모든 타입은 unknown 타입의 부분집합이다.
unknown 타입의 값은 any를 제외한 어떤 타입의 변수에도 할당할 수 없다.
let unknownValue: unknown;
let a: number = unknownValue;
// 오류 : unknown 타입은 number 타입에 할당할 수 없습니다.
2) never 타입 (공집합 타입)
never 타입은 타입 계층도에서 가장 아래에 위치한다.
never는 공집합을 뜻하는 타입이다. 수학에서의 공집합은 아무것도 포함하지 않는 집합이라는 뜻 이다.
따라서 never 타입에 해당하는 값은 말 그대로 아무것도 없다. 따라서 다음과 같은 상황에 never 타입이 주로 사용된다.
function errorFunc(): never {
throw new Error();
}
또 공집합은 모든 집합의 부분 집합이다. 그러므로 never 타입은 모든 타입의 서브 타입이다. 따라서 never 타입은 모든 타입으로 업캐스팅 할 수 있다.
let neverVar: never;
let a: number = neverVar; // never -> number
let b: string = neverVar; // never -> string
let c: boolean = neverVar; // never -> boolean
let d: null = neverVar; // never -> null
let e: undefined = neverVar; // never -> undefined
let f: [] = neverVar; // never -> Array
let g: {} = neverVar; // never -> Object
let a: never = 1; // number -> never ❌
let b: never = "hello"; // string -> never ❌
let c: never = true; // boolean -> never ❌
let d: never = null; // null -> never ❌
let e: never = undefined; // undefined -> never ❌
let f: never = []; // Array -> never ❌
let g: never = {}; // Object -> never ❌
3) void 타입
타입 계층도에서 void 타입을 찾아보면 void 타입은 undefined 타입의 슈퍼타입임을 알 수 있다.
따라서 반환값을 void로 선언한 함수에서 undefined을 반환 해도 오류가 발생하지 않는다. undefined 타입은 void 타입의 서브 타입이므로 업캐스팅이 가능하기 때문이다.
function noReturnFuncA(): void {
return undefined;
}
function noReturnFuncB(): void {
return;
}
function noReturnFuncC(): void {}
void 타입의 서브타입은 undefined 타입과 never 타입 밖에 없다. 따라서 void 타입에는 undefined, never 이외에 다른 타입의 값을 할당할 수 없다.
let voidVar: void;
voidVar = undefined; // undefined -> void (ok)
let neverVar: never;
voidVar = neverVar; // never -> void (ok)
4) any 타입 (치트키)
any 타입은 사실상 타입 계층도를 완전히 무시한다.
any는 뭐든지 예외다. 모든 타입의 슈퍼타입이 될 수도 있고 모든 타입의 서브 타입이 될 수도 있다.
let anyValue: any;
let num: number = anyValue; // any -> number (다운 캐스트)
let str: string = anyValue; // any -> string (다운 캐스트)
let bool: boolean = anyValue; // any -> boolean (다운 캐스트)
anyValue = num; // number -> any (업 캐스트)
anyValue = str; // string -> any (업 캐스트)
anyValue = bool; // boolean -> any (업 캐스트)
각각 number, string, boolean 타입을 갖는 변수 num, str, bool에 any 타입의 값을 할당한다. 이는 any 타입이 각각 number, string, boolean 타입으로 다운 캐스트 된다고 이해할 수 있다.
또 any 타입 변수 anyValue에 num, str, bool 변수에 담긴 값을 할당한다. 이는 number, string, boolean 타입이 모두 any 타입으로 업 캐스트 되는 것으로 이해할 수 있다.
이렇듯 any 타입은 모든 타입으로 다운캐스트 할 수 있으며 또 모든 타입은 any 타입으로 업 캐스트 할 수 있다.
4. 객체 타입의 호환성
객체 타입간의 호환성도 동일한 기준으로 판단한다. 모든 객체 타입은 각각 다른 객체 타입들과 슈퍼-서브 타입 관계를 갖는다.
type Animal = {
name: string;
color: string;
};
type Dog = {
name: string;
color: string;
breed: string;
};
let animal: Animal = {
name: "기린",
color: "yellow",
};
let dog: Dog = {
name: "돌돌이",
color: "brown",
breed: "진도",
};
animal = dog; // ✅ OK
dog = animal; // ❌ NO
Animal 타입의 변수 animal에 Dog 타입의 변수 dog를 할당하는 것은 가능하다. 그러나 반대로 dog 변수에 animal 변수의 값을 할당하는 것은 불가능하다. Animal 타입이 Dog 타입의 슈퍼타입이기 때문이다.
타입스크립트는 프로퍼티를 기준으로 타입을 정의하는 구조적 타입 시스템을 따른다. 따라서 Animal 타입은 name과 color 프로퍼티를 갖는 모든 객체들을 포함하는 집합으로 볼 수 있고, Dog 타입은 name과 color 거기에다 추가로 breed 프로퍼티를 갖는 모든 객체를 포함하는 집합으로 볼 수 있다.
그러므로 어떤 객체가 Dog 타입에 포함된다면 무조건 Animal 타입에도 포함된다. 그러나 반대로 Animal 타입에 포함되는 모든 객체가 Dog 타입에 포함되는것은 아니다. 따라서 결국 Animal은 Dog의 슈퍼타입이 된다.
# 초과 프로퍼티 검사
type Book = {
name: string;
price: number;
};
type ProgrammingBook = {
name: string;
price: number;
skill: string;
};
let book: Book;
let programmingBook: ProgrammingBook = {
name: "한 입 크기로 잘라먹는 리액트",
price: 33000,
skill: "reactjs",
};
book = programmingBook; // ✅ OK
programmingBook = book; // ❌ NO
let book2: Book = { // 오류 발생
name: "한 입 크기로 잘라먹는 리액트",
price: 33000,
skill: "reactjs",
};
Book 타입으로 정의된 변수에 ProgrammingBook 타입으로 보이는 초기값을 설정했더니 오류가 난다. 이것은 업캐스팅에 해당되는데 왜 오류가 발생하는 걸까?
결론부터 말하면 이것은 ‘초과 프로퍼티 검사’가 발동해서 그런 것이다. 초과 프로퍼티 검사란 변수를 객체 리터럴로 초기화 할 때 발동하는 타입스크립트의 특수한 기능이다. 이 기능은 타입에 정의된 프로퍼티 외의 다른 초과된 프로퍼티를 갖는 객체를 변수에 할당할 수 없도록 막는다.
따라서 위 코드는 Book 타입에 정의되지 않은 skill 프로퍼티를 갖는 객체를 할당하려고 했으므로 초과 프로퍼티 검사가 실패해 오류가 발생하고 있는 것이다.
이런 초과 프로퍼티 검사는 단순히 변수를 초기화 할 때 객체 리터럴을 사용하지만 않으면 발생하지 않는다. 따라서 다음과 같이 값을 별도의 다른 변수에 보관한 다음 변수 값을 초기화 값으로 사용하면 발생하지 않는다.
(...)
let book3: Book = programmingBook; // 앞서 만들어둔 변수
5. 대수 타입
대수 타입이란 여러개의 타입을 합성해서 만드는 타입을 말한다.
1) 합집합 Union 타입
다음과 같이 string과 number의 유니온 타입을 정의할 수 있고, 바 | 를 이용한다.
// 합집합 타입 - Union 타입
let a: string | number;
# Union 타입으로 배열 타입 정의하기
let arr: (number | string | boolean)[] = [1, "hello", true];
# Union 타입과 객체 타입
type Dog = {
name: string;
color: string;
};
type Person = {
name: string;
language: string;
};
type Union1 = Dog | Person;
따라서 다음과 같은 객체 들을 포함하는 타입이 된다.
(...)
let union1: Union1 = { // ✅
name: "",
color: "",
};
let union2: Union1 = { // ✅
name: "",
language: "",
};
let union3: Union1 = { // ✅
name: "",
color: "",
language: "",
};
반면 다음과 같은 객체는 포함하지 않는다.
let union4: Union1 = { // ❌
name: "",
};
그림으로 표현하자면 다음과 같다.
2) 교집합 Intersection 타입
다음과 같이 string과 number의 인터섹션 타입을 정의할 수 있고, &을 이용한다.
let variable: number & string;
// never 타입으로 추론된다
그런데 number 타입과 string 타입은 서로 교집합을 공유하지 않는 서로소 집합이므로 변수 variable의 타입은 결국 never 타입으로 추론된다.
대다수의 기본 타입들 간에는 서로 공유하는 교집합이 없기 때문에 이런 인터섹션 타입은 보통 객체 타입들에 자주 사용된다.
# Intersection 타입과 객체 타입
type Dog = {
name: string;
color: string;
};
type Person = {
name: string;
language: string;
};
type Intersection = Dog & Person;
let intersection1: Intersection = {
name: "",
color: "",
language: "",
};
위 코드의 Intersection 타입을 집합으로 표현하면 다음과 같다.
6. 타입 추론
타입스크립트는 타입이 정의되어 있지 않은 변수의 타입을 자동으로 추론하고, 이 기능을 “타입 추론”이라고 한다. 그러나 모든 상황에 타입을 잘 추론하는 것은 아니다. 예를 들어 다음과 같이 함수의 매개변수 타입은 자동으로 추론할 수 없다.
let a = 10;
// number 타입으로 추론
function func(param){ // 오류
}
# 타입 추론이 가능한 상황들
1) 변수 선언
일반적인 변수 선언의 경우 초기값을 기준으로 타입이 잘 추론된다.
let a = 10;
// number 타입으로 추론
let b = "hello";
// string 타입으로 추론
let c = {
id: 1,
name: "이정환",
profile: {
nickname: "winterlood",
},
urls: ["https://winterlood.com"],
};
// id, name, profile, urls 프로퍼티가 있는 객체 타입으로 추론
2) 구조 분해 할당
객체와 배열을 구조 분해 할당하는 상황에서도 타입이 잘 추론된다.
let { id, name, profile } = c;
let [one, two, three] = [1, "hello", true];
3) 함수의 반환값
함수 반환값의 타입은 return 문을 기준으로 잘 추론된다.
function func() {
return "hello";
}
// 반환값이 string 타입으로 추론된다
4) 기본값이 설정된 매개변수
기본값이 설정된 매개변수의 타입은 기본값을 기준으로 추론된다.
function func(message = "hello") {
return "hello";
}
# 주의해야 할 상황들
1) 암시적으로 any 타입으로 추론
변수를 선언할때 초기값을 생략하면 암시적인 any 타입으로 추론된다. 참고로 이때 파라미터의 타입이 암시적 any로 추론될 때와 달리 일반 변수의 타입이 암시적 any 타입으로 추론되는 상황은 오류로 판단하지 않는다.
let d;
// 암시적인 any 타입으로 추론
이 변수에 값을 할당하면 그 다음부터 any 타입이 해당 값의 타입으로 변한다.
let d;
d = 10;
d.toFixed();
d = "hello";
d.toUpperCase();
d.toFixed(); // 오류
d = 10; 다음 라인부터는 d가 number 타입이 되고, d = “hello” 다음 라인부터는 d가 string 타입이 된다. 따라서 마지막 라인에서 d가 string 타입일 때 toFixed 같은 number 타입의 메서드를 사용하려고 하면 오류가 발생한다. 이렇게 암시적으로 추론된 any 타입은 코드의 흐름에 따라 타입이 계속 변화한다. 이를 any의 진화라고 표현하기도 한다.
2) const 상수의 추론
const로 선언된 상수는 let으로 선언한 변수와는 다른 방식으로 추론된다. 상수는 초기화 때 설정한 값을 변경할 수 없기 때문에 리터럴 타입으로 추론된다.
const num = 10;
// 10 Number Literal 타입으로 추론
const str = "hello";
// "hello" String Literal 타입으로 추론
# 최적 공통 타입
다음과 같이 다양한 타입의 요소를 담은 배열을 변수의 초기값으로 설정하면, 최적의 공통 타입으로 추론된다.
let arr = [1, "string"];
// (string | number)[] 타입으로 추론
7. 타입 단언
변수 person은 Person 타입으로 정의 되었지만 초기화 할 때에는 빈 객체를 넣어두고 싶다고 가정해보자다. 그러나 타입스크립트에서는 이런 경우를 허용하지 않는다. 빈 객체는 Person 타입이 아니므로 오류가 발생하게 된다.
type Person = {
name: string;
age: number;
};
let person: Person = {};
person.name = "";
person.age = 23;
이럴 땐 다음과 같이 이 빈 객체를 Person 타입이라고 타입스크립트에게 단언해주면 된다.
type Person = {
name: string;
age: number;
};
let person = {} as Person;
person.name = "";
person.age = 23;
이렇듯 값 as 타입 으로 특정 값을 원하는 타입으로 단언할 수 있고, 이를 타입 단언 이라고 부른다.
타입 단언은 다음과 같이 초과 프로퍼티 검사를 피할때에도 요긴하게 사용할 수 있다.
type Dog = {
name: string;
color: string;
};
let dog: Dog = {
name: "돌돌이",
color: "brown",
breed: "진도",
} as Dog
# 타입 단언의 조건
값 as 타입 형식의 단언식을 A as B로 표현했을 때 아래의 두가지 조건 중 한 가지를 반드시 만족해야 한다.
- A가 B의 슈퍼타입이다
- A가 B의 서브타입이다
let num1 = 10 as never; // ✅
let num2 = 10 as unknown; // ✅
let num3 = 10 as string; // ❌
# 다중 단언
타입 단언은 다중으로도 가능하다. 다중 단언을 이용하면 앞서 살펴본 예제 중 불가능했던 단언을 다음과 같이 가능하도록 만들 수도 있다. 하지만 이것은 단순 눈속임에 불과하므로 오류가 발생할 확률이 매우 높다.
let num3 = 10 as unknown as string;
# const 단언
타입 단언때에만 사용할 수 있는 const 타입이 존재한다. 특정 값을 const 타입으로 단언하면 마치 변수를 const로 선언한 것 과 비슷하게 타입이 변경된다.
let num4 = 10 as const;
// 10 Number Literal 타입으로 단언됨
let cat = {
name: "야옹이",
color: "yellow",
} as const;
// 모든 프로퍼티가 readonly를 갖도록 단언됨
# Non Null 단언
값 뒤에 느낌표(!) 를 붙여주면 이 값이 undefined이거나 null이 아닐것으로 단언할 수 있다.
type Post = {
title: string;
author?: string;
};
let post: Post = {
title: "게시글1",
};
const len: number = post.author!.length;
8. 타입 좁히기
조건문을 이용해 조건문 내부에서 변수가 특정 타입임을 보장하면 해당 조건문 내부에서는 변수의 타입이 보장된 타입으로 좁혀진다. 따라서 첫번째 조건문 내부에서는 value의 타입이 number 타입이 되고, 두번째 조건문 내부에서는 value의 타입이 string 타입이 된다. 이를 타입 좁히기 라고 표현한다.
또 if (typeof === …) 처럼 조건문과 함께 사용해 타입을 좁히는 이런 표현들을 “타입 가드”라고 부른다. 타입 가드를 이용해 타입을 좁혀 사용할 수 있다.
function func(value: number | string) {
if (typeof value === "number") {
console.log(value.toFixed());
} else if (typeof value === "string") {
console.log(value.toUpperCase());
}
}
# instance of 타입가드
instanceof를 이용하면 내장 클래스 타입을 보장할 수 있는 타입가드를 만들 수 있다.
function func(value: number | string | Date | null) {
if (typeof value === "number") {
console.log(value.toFixed());
} else if (typeof value === "string") {
console.log(value.toUpperCase());
} else if (value instanceof Date) {
console.log(value.getTime());
}
}
그러나 Instanceof는 내장 클래스 또는 직접 만든 클래스에만 사용 가능한 연산이다. 따라서 우리가 직접 만든 타입과 함께 사용할 수 없다.
# in 타입가드
우리가 직접 만든 타입과 함께 사용하려면 다음과 같이 in 연산자를 이용하여 프로퍼티 in 파라미터 형식을 사용해야 한다.
in 연산자 뒤에는 null 이나 undefined 값이 들어오면 안되므로 value가 있을 경우에만을 선별하는 value && 를 앞에 써준다.
type Person = {
name: string;
age: number;
};
function func(value: number | string | Date | null | Person) {
if (typeof value === "number") {
console.log(value.toFixed());
} else if (typeof value === "string") {
console.log(value.toUpperCase());
} else if (value instanceof Date) {
console.log(value.getTime());
} else if (value && "age" in value) {
console.log(`${value.name}은 ${value.age}살 입니다`)
}
}
9. 서로소 유니온 타입
서로소 유니온 타입은 교집합이 없는 타입들 즉 서로소 관계에 있는 타입들을 모아 만든 유니온 타입을 말한다. 동시에 여러 가지 상태를 표현해야하는 객체의 타입을 정의할 때는 선택적 프로퍼티(?를 적어주는 것)보다는 서로소 유니온 타입을 사용하는 것이 더 직관적이고 안전하다.
type Admin = {
name: string;
kickCount: number;
};
type Member = {
name: string;
point: number;
};
type Guest = {
name: string;
visitCount: number;
};
type User = Admin | Member | Guest;
function login(user: User) {
if ("kickCount" in user) {
// Admin
console.log(`${user.name}님 현재까지 ${user.kickCount}명 추방했습니다`);
} else if ("point" in user) {
// Member
console.log(`${user.name}님 현재까지 ${user.point}모았습니다`);
} else {
// Guest
console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다`);
}
}
회원의 역할 분류에 따라 3개의 타입을 각각 정의해 주었고 이 3개의 타입의 합집합 타입인 User 타입도 만들어 주었다.
login 함수는 User 타입의 매개변수 user를 받아 회원의 역할에 따라 각각 다른 기능을 수행하도록 한다.
- 첫번째 조건문이 참이되면 user에 kickCount 프로퍼티가 있으므로 이 유저는 Admin 타입으로 좁혀진다.
- 두번째 조건문이 참이되면 user에 point 프로퍼티가 있으므로 이 유저는 Member 타입으로 좁혀진다.
- 세번째 else 문까지 오면 user는 남은 타입인 Guest 타입으로 좁혀진다.
그러나 이렇게 코드를 작성하면 조건식만 보고 어떤 타입으로 좁혀지는지 바로 파악하기가 좀 어렵고, 결과적으로 직관적이지 못한 코드이다.
이럴 때에는 다음과 같이 각 타입에 태그 프로퍼티를 추가해주면 된다. 서로소 유니온 타입에서는 주로 이 방식을 많이 사용한다.
type Admin = {
tag: "ADMIN";
name: string;
kickCount: number;
};
type Member = {
tag: "MEMBER";
name: string;
point: number;
};
type Guest = {
tag: "GUEST";
name: string;
visitCount: number;
};
type User = Admin | Member | Guest;
(...)
switch를 이용해 더 직관적으로 변경할 수도 있다.
function login(user: User) {
switch (user.tag) {
case "ADMIN": {
console.log(`${user.name}님 현재까지 ${user.kickCount}명 추방했습니다`);
break;
}
case "MEMBER": {
console.log(`${user.name}님 현재까지 ${user.point}모았습니다`);
break;
}
case "GUEST": {
console.log(`${user.name}님 현재까지 ${user.visitCount}번 오셨습니다`);
break;
}
}
}
'온라인 강의(유데미, 인프런 등) > 한 입 크기로 잘라먹는 타입스크립트(인프런)' 카테고리의 다른 글
[한 입 크기로 잘라먹는 타입스크립트] 타입 조작하기 (0) | 2023.06.20 |
---|---|
[한 입 크기로 잘라먹는 타입스크립트] 제네릭 (0) | 2023.06.19 |
[한 입 크기로 잘라먹는 타입스크립트] 인터페이스 (0) | 2023.06.19 |
[한 입 크기로 잘라먹는 타입스크립트] 함수 타입 (0) | 2023.06.17 |
[한 입 크기로 잘라먹는 타입 스크립트] 타입스크립트 기본 (0) | 2023.06.16 |