타입스크립트, 집합으로 이해하기
TIL 23. 12. 16
타입스크립트 이해하기
타입스크립트의 ‘타입’은 사실 여러 개의 값을 포함하는 ‘집합’이다. 여러 개의 숫자 값들(1, -20, Infinity, NaN 등)을 묶어 놓은 집합은 Number 타입이 된다.
집합: 동일한 속성을 갖는 여러 개의 요소들을 하나의 그룹으로 묶은 단위
하나의 값만 포함하는 Number Literal 타입은 Number 타입에 속하는 부분 집합으로 볼 수 있다.
타입스크립트의 모든 타입들은 집합으로써 서로 포함하고 포함되는 관계를 갖는다. 이런 관계에서 다른 타입을 포함하는 타입을 슈퍼 타입(부모 타입), 반대는 서브 타입(자식 타입)이라고 한다.
타입 호환성
타입 호환성이란 A와 B 두 개의 타입이 존재할 때 A 타입의 값을 B 타입으로 취급해도 괜찮은지 판단하는 것을 의미한다. 만약 A 타입의 값이 B 타입의 값으로 취급되어도 괜찮다면 호환된다고 한다.
예를 들어 Number Literal 타입의 값인 10은 Number 타입의 값으로 취급하는 것이 가능하다. 그러나 반대로는 불가능하다. Number 타입이 더 큰 타입이기 때문에.
let num1: number = 10;
let num2: 10 = 10;
num1 = num2; // ✅ OK
num2 = num1; // ❌ Error: Type 'number' is not assignable to type '10'
타입스크립트에서는 슈퍼 타입의 값을 서브 타입의 값으로 취급하는 것을 허용하지 않는다. 서브 타입의 값을 슈퍼 타입의 값으로 취급하는 것을 업 캐스팅이라 부르고, 반대는 다운 캐스팅이라고 부른다.
추가 읽을거리 TypeScript 타입 시스템 뜯어보기: 타입 호환성
타입 계층도와 함께 기본타입 살펴보기
unknown 타입 (전체 집합)
unknown 타입은 타입 계층도의 최상단에 위치한다. 따라서 unknown 타입 변수에는 모든 타입의 값을 할당할 수 있다. unknown 타입은 모든 타입을 부분 집합으로 갖는 타입스크립트 전체 집합이다. 즉, 모든 타입은 unknown 타입으로 업 캐스트 할 수 있다.
하지만 반대는 불가능하다. unknown 타입의 값은 any를 제외한 어떤 타입의 변수에도 할당할 수 없다. 다운 캐스팅이 불가능하다.
let a: unknown = 1;
let b: unknown = "hello";
let c: unknown = true;
let d: unknown = null;
let e: unknown = undefined;
let f: unknown = [];
let g: unknown = {};
let h: unknown = () => {};
let i: unknown;
let j: number = i; // ❌ Error: Type 'unknown' is not assignable to type 'number'.
never 타입 (공집합 타입)
never 타입은 타입 계층도에서 가장 아래에 위치한다. 집합의 관점에서 보면 never는 공집합을 뜻하는 타입이다.
공집합: 아무것도 포함하지 않는 집합
never 타입에 해당하는 값은 아무것도 없었다. 무한루프와 같이 무언가를 반환할 수 없었기 때문이다.
공집합은 모든 집합의 부분 집합이다. 그러므로 never 타입은 모든 타입의 서브 타입이다. never 타입은 모든 타입으로 업 캐스팅 할 수 있다. 반대는 불가능하다.
function neverFunc() {
function neverFunc(): never {
while (true) {}
}
let a: number = neverFunc();
let b: string = neverFunc();
let c: never = 10; // ❌ Error: Type 'number' is not assignable to type 'never'
}
void 타입
void 타입은 아무것도 반환하지 않는 함수의 반환값 타입으로 주로 사용된다. void 타입은 undefined 타입의 슈퍼 타입이다. 반환값이 void인 함수에서 undefined을 반환 해도 오류가 발생하지 않는 이유이다. undefined 타입은 void 타입의 서브 타입이므로 업 캐스팅이 가능하기 때문에. void 타입의 서브 타입(undefined, never) 외에는 다른 타입의 값을 할당할 수 없다.
let voidVar: void;
voidVar = undefined;
let neverVar: never;
voidVar = neverVar;
any 타입
any 타입은 타입 계층도를 완전히 무시한다. 모든 타입의 슈퍼 타입이 될 수도 있고 모든 타입의 서브 타입이 될 수도 있다.
단, never 타입의 변수에는 any 타입의 값을 할당할 수는 없다. never 타입은 공집합이므로 어떤 타입도 다운 캐스팅 할 수 없다.
let anyValue: any;
let num: number = anyValue; // any -> number
let str: string = anyValue; // any -> string
let bool: boolean = anyValue; // any -> boolean
let neverVar: never = anyValue; // ❌ Error: Type 'any' is not assignable to type 'never'
any 타입은 알지 못하는 타입을 표현해야 하는 경우(서드 파티 라이브러리 등)에만 사용하도록 권장한다. any 타입을 남발하면 타입스크립트를 쓰는 의미가 없다.
객체 타입의 호환성
모든 객체 타입 역시 각각 다른 객체 타입들과 슈퍼-서브 타입 관계를 갖는다. 업 캐스팅은 허용하고 다운 캐스팅은 불가능하다.
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
Dog 타입 변수 dog를 Animal 타입의 변수 animal에 할당하는 것은 가능하나 반대는 불가능하다. 이를 통해 Animal 타입이 Dog 타입의 슈퍼 타입임을 알 수 있다. Dog 타입이 더 많은 프로퍼티를 정의하고 있어 슈퍼 타입처럼 보일 수 있지만 실제로는 반대이다.
그 이유는 타입스크립트는 프로퍼티를 기준으로 타입을 정의하는 구조적 타입 시스템을 따르기 때문이다.
Animal 타입은 프로퍼티로 name과 color를 포함하고 Dog 타입은 추가로 breed 프로퍼티를 포함한다. Dog 타입은 Animal 타입의 name과 color를 반드시 포함한다. 그러므로 어떤 객체가 Dog 타입이라면 무조건 Animal 타입에도 포함된다. 아래 글이 많은 도움이 되었다.
객체 타입들 간 관계를 정의할 때 breed와 같이 추가 프로퍼티를 가진 타입이 서브 타입, 조건이 더 적은 타입이 슈퍼 타입이 된다.
초과 프로퍼티 검사
type Animal = {
name: string;
color: string;
};
type Dog = {
name: string;
color: string;
breed: string;
};
let dog: Animal = {
name: "둘둘이",
color: "ivory",
breed: "진도", // ❌ Error
}
초과 프로퍼티 검사란 변수를 객체 리터럴로 초기화 할 때 발동하는 타입스크립트의 특수한 기능이다. 이 기능은 타입에 정의된 프로퍼티 외의 다른 초과된 프로퍼티를 갖는 객체를 변수에 할당할 수 없도록 막는다.
초과 프로퍼티 검사를 피하려면 객체 리터럴을 사용하지 않으면 된다. 변수에 미리 값을 담아둔 다음 변수 값을 초기화 값으로 사용하면 초과 프로퍼티 검사가 발동하지 않는다.
객체 리터럴로 초기화되는 경우, 초과 프로퍼티 검사를 통해 예기치 않은 프로퍼티가 추가되는 것을 방지한다.
대수 타입
대수 타입이란 여러 개의 타입을 합성해서 만드는 타입을 말한다. 대수 타입에는 합집합(Union) 타입과 교집합(Intersection) 타입이 존재한다.
합집합 타입
합집합은 집합 A에 속하거나 집합 B에 속하는 원소로 이루어진 집합으로, A, B 집합 중 아무데나 속하면 된다. |
연산자를 이용하여 유니온 타입을 정의할 수 있다.
let a: string | number;
let arr: (number | string | boolean)[] = [1, "hello", true];
변수 a에는 string 타입과 number 타입에 해당하는 값을 저장할 수 있다. 유니온 타입에 참여하는 타입들의 개수에는 제한이 없다.
▶ Union1 타입으로 정의한 여러 개의 객체 타입 변수
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: "",
};
Dog 타입의 객체(union1)와 Person 타입의 객체(union2) 그리고 두 타입의 모든 프로퍼티를 갖는 객체(union3)은 허용이 되지만 두 프로퍼티를 공통으로 가지는 객체(union4)는 허용되지 않는다. union4는 Dog 타입과 Person 타입의 교집합 타입의 변수라고 생각할 수 있지만 그렇지 않다.
교집합 타입
두 집합 A와 B의 교집합 A ∩ B는 그 두 집합이 공통으로 포함하는 원소로 이루어진 집합이다. 교집합 타입을 정의하려면 &
을 이용한다.
let variable: number & string; // never
number 타입과 string 타입은 서로소 집합이므로 never 타입으로 추론된다. 대다수의 기본 타입들 간에는 서로 공유하는 교집합이 없기 때문에 교집합 타입은 보통 객체 타입들에 자주 사용된다.
type Dog = {
name: string;
color: string;
};
type Person = {
name: string;
language: string;
};
type DogPerson = Dog & Person;
let dogPerson: DogPerson = {
name: "",
color: "",
language: "",
};
위 코드의 Intersection 타입을 집합으로 표현하면 다음과 같다.
(+) Dog 타입과 Person 타입의 교집합이면 DogPerson 타입은 name 프로퍼티만 가져야 하는 것 아닌가?
DogPerson 타입은 Dog 타입에도 속하고 Person 타입에도 속할 수 있는 각 타입의 서브 타입이다. name 프로퍼티만 가진다면 DogPerson이 두 타입의 슈퍼 타입이 된다.
dogPerson 변수의 language 프로퍼티가 없다면 Person 타입에 속하지 않고, color 프로퍼티가 없다면 Dog 타입에 속하지 않는다.
타입간의 교집합은 타입들의 모든 속성을 포함해야 한다.
타입 추론
타입스크립트는 점진적 타입 시스템을 채택한다고 했다. 타입이 정의되어 있지 않은 변수의 타입을 변수에 초기화 된 값을 기준으로 알아서 추론한다. 그러나 모든 상황에 타입을 잘 추론하는 것은 아니다.
function func(param) { // ❌ Error
}
함수의 매개변수 타입은 자동으로 추론할 수 없다. 타입 추론이 불가능한 변수에는 암묵적으로 any 타입이 추론된다. 변수를 선언할 때 초기값을 생략했을 때도 any 타입으로 추론한다. 일반 변수의 타입이 암시적 any 타입으로 추론되는 상황은 오류로 판단하지 않는다.
let a; // any
a = 10;
console.log(a); // number
암시적으로 any 타입이 추론된 변수에 값을 할당하면 다음 라인부터 해당 값의 타입으로 변화한다. 이를 any의 진화라고 표현하기도 한다.
const num = 10; // 10 Number Literal
const로 선언된 상수는 값을 변경할 수 없기 떄문에 가장 좁은 타입으로 추론된다.
타입 단언
만약, 빈 객체로 초기화하고 이후에 할당하고 싶다면? 값 as 타입
으로 특정 값을 원하는 타입으로 단언할 수 있다. 이를 타입 단언이라고 부른다. 초과 프로퍼티 검사를 피할 때에도 요긴하게 사용할 수 있다.
type Dog = {
name: string;
color: string;
};
let dog: Dog = {
name: "돌돌이",
color: "brown",
breed: "진도",
} as Dog
타입 단언의 조건
단, 조건이 있다. 값 as 타입
형식의 단언식을 A as B
로 표현했을 때 아래 두 가지 조건 중 한 가지를 반드시 만족해야 한다.
- A가 B의 슈퍼타입
- A가 B의 서브타입
const 단언
특정 값을 const 타입으로 단언하면 마치 변수를 const로 선언한 것과 비슷하게 타입이 변경된다. const 단언을 이용하면 객체를 초기화할 때 모든 프로퍼티가 readonly가 되어 상황에 따라 유용하게 사용할 수 있다.
let num = 10 as const; // 10 Number Literal
let person = {
name: 'Lee', // readonly name: "Lee"
age: 20, // readonly age: 20
} as const;
타입 단언은 실제로 타입을 변경하는 것이 아니라 타입스크립트 컴파일러의 눈을 속이는 것이므로 조심해서 사용해야 한다.
타입 좁히기
조건문 등을 이용해 넓은 타입에서 좁은 타입으로 타입을 상황에 따라 좁히는 방법을 타입 좁히기라고 한다.
function func(value: number | string) {
if (typeof value === "number") {
console.log(value.toFixed()); // value: number
} else if (typeof value === "string") {
console.log(value.toUpperCase()); // value: string
}
}
조건문을 이용해 조건문 내부에서 변수가 특정 타입임을 보장하면 해당 조건문 내부에서는 변수의 타입이 보장된 타입으로 좁혀진다.
if (typeof === …)
처럼 조건문과 함께 사용해 타입을 좁히는 이런 표현들을 “타입 가드”라고 부른다. 타입에 따라 다른 동작을 수행하고자 할 때 타입 가드를 활용.
instanceof 타입 가드
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()); // value: Date
}
}
Instanceof
는 내장 클래스 또는 직접 만든 클래스에만 사용이 가능한 연산이다. 따라서 직접 만든 타입과 함께 사용하려면 in 연산자를 이용해야 한다.
in 타입 가드
자바스크립트에서 in
연산자는 명시된 속성이 명시된 객체에 존재하면 true
를 반환한다.
var myCar = {
company: "Lamborghini",
model: "Lamborghini Veneno Roadster",
year: 2014,
};
"company" in myCar; // false
in
연산자을 사용하면 타입스크립트 컴파일러가 속성 이름으로 변수의 타입을 좁힐 수 있다.
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}살 입니다`)
}
}
in
연산자 뒤에는 undefined과 null이 오지 못하므로 value의 값이 있음을 보장하기 위해 &&
연산자 사용
댓글남기기