[타입스크립트의 이해]
타입을 정의하는 기준, 타입간의 관계를 정의하는 기준, 타입의 오류를 검사하는 기준 등 타입스크립트의 구체적인 원리와 동작 방식을 이해한다.
[타입은 집합이다]
타입은 집합으로 이해를 하면 쉽다. 타입은 값들을 포함하고 있는 집합이며 계층도로 표현이 가능하다.
타입 계층도
슈퍼타입(부모 타입) : 상대적으로 더 큰 범주의 타입 및 집합
서브타입(자식 타입) : 상대적으로 더 작은 범주의 타입 및 집합
//number type은 number literal type의 슈퍼 타입이다.
const a:number = 1; //number type
const b:10 = 10; // number literal type
타입 호환성
타입 호환성 : 어떤 타입을 다른 타입으로 취급해도 괜찮은지 판단하는 것. 일반적으로 업캐스팅은 대부분 가능하지만 다운 캐스팅은 거의 불가능하다.
const a:number = 1; //number type
const b:10 = 10; // number literal type
/**
*a와 b가 모두 type 자체는 숫자 형태라고 하더라도 b는 10이라는 숫자 하나만을 type으로 명시하고 있고
*a는 number 전체 범주를 명시하고 있기 때문에 a를 b라고 보기엔 어려움이 있다.
*/
업 캐스팅 : 서브 타입을 슈퍼 타입으로 취급, 모든 상황에 가능
다운 캐스팅 : 슈퍼 타입을 서브 타입으로 취급, 대부분 불가능
unknown type은 모든 type의 슈퍼 타입이며, never type은 모든 타입의 서브 타입이다. 쉽게 코드로 보면 다음과 같다.
//Unknown type
function unknownExam(){
let a:unknown = 1; //Upcasting
let b:unknown = 'a'; //Upcasting
let c:unknown = true; //Upcasting
let d:unknown = null; //Upcasting
let e:unknown = undefined; //Upcasting
let unknownVar: unknown;
let num: number = unknownVar; //Error
let str:string = unknownVar; //Error
let bool:boolean = unknownVar;//Error
}
//Never type
function neverExam(){
function neverFunc():never {
while(true){}
}
let num:number = neverFunc(); //Upcasting
let str:string = neverFunc(); //Upcasting
let bool:boolean = neverFunc();//Upcasting
let never1:never = 10; //Error
}
//Void type
function voidExam(){
function voidFunc():void {
console.log("hi");
}
let voidVar: void = undefined; //void type은 undefined type의 슈퍼타입
}
//any type
function anyExam(){
let unknownVar:unknown;
let anyVar: any;
let undefinedVar:undefined;
let neverVar:never;
//any type은 모든 type의 슈퍼 타입도 가능하고 서브 타입도 가능하다.
anyVar = unknownVar;
anyVar = undefinedVar;
undefinedVar = anyVar;
anyVar = neverVar; //Error
neverVar = anyVar; //Error
//any type은 모든 type 간의 관계에서 자유롭지만 never type한테만큼은 자유롭지 못하다.
}
[객체 타입의 호환성]
객체 타입의 호환성
특정 객체 타입을 다른 객체 타입으로 취급해도 괜찮은지에 대한 호환성을 따지는 것이다.
앞에서 말했던 슈퍼 타입과 서브 타입 간의 관계를 보면 서브 타입이 더 작은 범주라는 것을 알 수 있다.
위의 예시를 다시 보면
const a:number = 10;
const b:10 = 10;
위의 예시에서 a는 number type이라는 더 큰 범주를 갖고 있고 b는 number literal type으로 10이라는 값 하나만 갖고 있기 때문에 a가 b의 슈퍼 타입이라는 것을 알 수 있다.
하지만 객체에서의 호환성을 보면 헷갈리게 되는데 객체로 예시를 들어보면
type Animal = {
name: string,
age: number
}
type Dog = {
name: string,
age: number,
breed: string
}
//super type
let animal: Animal = {
name: 'Tom',
age: 10
}
//serve type
let dog: Dog = {
name: 'mike',
age: 5,
breed: 'mal'
}
animal = dog; // up casting
dog = animal; //Error, down casting
Type Alias로 작성된 Animal type과 Dog type을 비교했을 때 Dog type이 더 많은 프로퍼티를 갖고 있기 때문에 더 큰 범주가 아니냐고 헷갈릴 수 있지만 TS에서는 누구나 갖고 있는 프로퍼티를 갖고 있는 type이 슈퍼 타입이 된다.
따라서 아래의 animal과 dog 변수로 UpCasting과 DownCasting을 하였을 때 DownCasting에서 에러가 난 것으로 증명되며 객체 타입의 호환성에 대해 알 수 있다.
초과 프로퍼티 검사
객체 타입의 변수를 초기화 할 때, Type Alias로 정의해두지 않은 프로퍼티를 추가로 입력하였을 때 자동적으로 오류를 발생시키는 검사이다. 말로만 들으면 어려우니 간단한 코드로 보면
type Book = {
name: string,
price: number
}
let JSBook: Book = {
name: "This is JSBook",
price: 20000,
skills: "Javscript" //Error
}
Type Alias로 정의해둔 Book 타입에는 skills라는 프로퍼티가 존재하지 않지만 새로 초기화를 하는 JSBook이라는 변수에는 skills라는 프로퍼티가 추가되어있다. 이러한 경우 기존에 정의되어 있지 않는 프로퍼티를 초과하여 입력하였기 때문에 TS 자체적으로 초과 프로퍼티 검사를 통해서 에러를 발생시킨다.
초과 프로퍼티 검사의 에러를 방지하기 위해서는 아래 코드와 같이 객체 리터럴로 변수를 초기화하지 않고 추가하고자하는 프로퍼티가 포함되어있는 타입을 새로 정의한 후 할당하는 것이 좋다.
//Type Alias
type Book = {
name: string;
price: number;
}
type NewBook = {
name: string;
price: number;
skills: string
}
//초과 프로퍼티 검사를 피하기 위한 변수 초기화
let codeBook:NewBook = {
name: "Typescript",
price: 20000,
skills: "typescript"
}
//객체 리터럴 타입이 아닌 초기화 되어있는 변수를 할당
let book1: Book = codeBook;
[대수 타입]
대수 타입이란?
여러 개의 타입을 합성해서 새롭게 만들어 낸 타입, Union Type과 Intersection Type이 있다.
Union Type(합집합 타입)
- 우리가 흔히 알고 있는 합집합이라고 생각하면 쉽다. 서로 다른 두 타입 모두를 허용하는 타입을 말한다. 코드로 보면 이해가 더 쉽기 때문에 아래에 예시를 보자.
let a:number | string | boolean;
a = 10; //Not Error
a = "hello" //Not Error
a = true //Not Error
let arr: (number | string | boolean)[] = [1,"hello",false]; //Not Error
//객체 타입
type User = {
name: string;
age: number
}
type Lang = {
name: string;
lan: string;
}
type Union = User | Lang;
let person: Union = {
name:"Tom",
age: 10
}
let person1: Union = {
name:"Bob",
lan: "kor"
}
let person2: Union = {
name:"Mike",
age: 20,
lan: "en"
}
변수 a와 arr에 Union Type이 정의되었기 때문에 정의된 타입의 자료형이라면 어떤 변수를 할당해도 오류가 발생하지 않는다. 또한 객체 형태의 Union으로 정의된 타입은 User 타입과 Lang 타입의 합집합 개념으로 변수 person2와 같이 두 타입에 정의되어있는 속성을 모두 사용해도 오류가 발생하지 않는 것이다.
Intersection Type(교집합 타입)
- 쉽게 말해 교집합 타입이다. 교집합의 개념과 같에 두 타입 모두의 서브 타입을 의미한다고 생각해도 된다. 하지만 기본 타입끼리의 Intersection Type은 대체로 Never Type으로 추론이 될 것이다. 기본 타입 간에는 겹치는 부분이 없기 때문이다.
let a: number & string; //typeof a === never
//number type과 string type의 공통 서브 타입은 never type뿐이다.
따라서 Intersection Type은 일반적으로 객체 자료형에서 자주 사용된다. 다른 속성을 가진 두 타입이 정의되었을 경우 특정 변수가 두 타입의 속성 모두를 갖고 있다면 두 타입 모두의 서브 타입이 된다. 쉽게 말해 두 집합(타입)의 교집합(Intersection Type)이 되는 것이다.
type Dog = {
name: string;
age: number
}
type Person = {
name: string;
language: string;
}
type Intersection = Dog & Person;
let intersection: Intersection = {
name: "Tom",
age: 10,
language: "kor"
}
let intersection1: Intersection = {
name: "Tom",
age: 10,
} // Error
[타입 추론]
타입 추론
- 타입을 정의하지 않은 특정 변수에 타입을 추론할만한 근거가 있다면 TS 자체적으로 타입을 추론하는 방식. JS와 같이 키워드, 변수명, 초기값만을 적었을 때 TS 자체적으로 해당 변수의 타입을 추론하게 된다. 다만 할당된 값에 의해서 추론을 하기 때문에 추론을 할 수 있는 근거가 있어야 추론이 가능해진다.
let a = 10;
let b = "hello";
let c = {
name:"tom",
age: 10
}
/**타입 추론
* a : number
* b : string
* c : { name: string; age:number }
*/
타입 추론은 TS 자체적으로 타입 넓히기라는 방식으로 진행된다. 변수에 할당된 값을 보고 범위를 넓혀서 타입을 추론하는 방법이다.
Any Type의 진화(암묵적 Any Type)
- 변수에 명시적으로 Any Type을 정의하지 않고 초기화하지 않았을 경우 자동으로 Any Type으로 추론이 된다. 이 때 특정 값을 할당해줄 때마다 할당 받은 값에 따라 타입을 추론하여 자동으로 변수의 타입이 정의되고 변하는데 이것을 Any Type의 진화, 암묵적 Any Type이라고 한다.
let a;
a = 10; // typeof a === number
a.tofixed();
a = "hello"; // typeof a === string
a.toUpperCase();
a.toFixed(); //Error
하지만 이 방식을 추천하지 않는다. 오류 발생의 확률이 높으며 협업 시 문제가 되기 때문이다.
Const 키워드로 선언된 상수의 타입
- JS에서 공부해서 알듯이 const 키워드로 선언된 변수 즉, 상수는 값이 변하지 않고 값을 재할당 할 수 없다. 따라서 const로 선언된 변수는 Literal Type으로 여겨진다.
[타입 단언(Type Assertion)]
타입 단언
- "변수의 값 as 단언"의 형태로 타입의 추론을 위해서 타입을 단언해주는 것을 말한다. 주로 객체 타입에서 사용되는데 이전에 알아보았듯이 객체 리터럴 타입이 아닌 object나 "{ }(중괄호)"로 타입을 정해주면 내부 속성에는 접근할 수 없다. 이러한 경우 Type Alias로 정의해둔 타입과 속성값이 있다면 as를 사용하여 타입을 단언해줄 수 있다. 아래 예시를 보자.
type User = {
name: string,
age: number
}
let user = {}; //Error
user.name = "Tom"; // Error
user.age = 10; //Error
//Type Assertion
let user = {} as User;
let user = <User>{}; //Can't use in .tsx file
user.name = "Tom"; // Not Error
user.age = 10; // Not Error
위의 예시 코드와 같이 as를 사용해서 타입 단언을 해주면 기존에 알고 있던 오류가 발생하지 않는다.
타입 단언은 변수의 타입이 정의되어있지 않은 경우 TS 컴파일러에게 "해당 변수는 as로 단언해주는 타입을 가진 것이니 에러를 발생시키지 말아라"라고 명령하는 것 같은 개념이다. 컴파일러를 눈속임하는 느낌이다.
하지만 위 코드처럼 타입 단언을 한 뒤 속성과 속성값을 변수를 빈 객체로 초기화 한 후 입력해주지 않고 빈 객체로 두면 역시나 오류가 발생한다. TS에서 특정 타입이라고 단언을 해놓고 왜 아무 데이터도 존재하지 않는가에 의구심을 갖고 에러를 발생시키는 것이다. 하지만 이 오류는 Syntax Error(문법적 오류)가 아닌 Runtime Error(런타임 오류)로 컴파일 단계를 건너뛰고 에러가 발생하기 때문에 TS의 장점을 활용하지 못한 좋지 못한 케이스가 된다. 이런 이유로 인해 타입 단언을 가능한 사용하지 말라고들 권한다.
타입 단언은 앞서 살펴본 초과 프로퍼티 검사도 무난히 통과가 된다.
type Man = {
name: string,
age: number,
}
let human = {
name: "Tom",
age: 10,
language: "kor"
} as Man; //Type Assertion, Not Error
let human = {
name: "Tom",
age: 10,
language: "kor" //Error, Exceed Property
}
타입 단언의 규칙
- "변수의 값 as 단언"의 단언 형태에서 변수의 값과 단언의 관계가 한 쪽의 슈퍼 타입이거나 서브 타입이어야 한다. 타입 간의 관계를 잘 알고 있어야 타입 단언의 규칙을 어기지 않을 수 있다.
let a = 10 as never; //never type is serve type of number type
let b = 10 as unknown; //unknown type is super type of number type
let c = 10 as string; //Error, string type is not serve,super type of number type
다중 단언
- unknown은 모든 타입의 슈퍼 타입이므로 타입 단언을 한 과정 거쳐서 만능으로 바뀌는 과정을 말한다.
let a = 10 as unknown as string;
/**unknown type is super type of all of type. Therefore, type asserting from number type
* to string type don't cause error
*/
하지만 Any Type과 같은 치트키 느낌이므로 피치 못할 사정이 아니면 사용하지 않는 것이 좋다.
const 단언
- 객체 내 모든 속성 값을 readonly 속성으로 전환한다. 특정 객체의 타입을 const로 단언을 하면 해당 객체의 속성들은 모두 읽기 전용 속성으로 전환된다.
let man = {
name: "Tom",
age: 10,
lan: "en"
} as const
man.age = 20; //Error, man.age property is readonly
man.lan = "kor"; //Error, man.lan property is readonly
Non Null 단언
- Optional Chaining 방식의 보완 방식이라고 보면 쉽다. Type Alias의 정의에서 Optional Property를 정의하였을 때 객체의 해당 속성에 접근을 하기 위해서는 JS의 문법 Optional Chaining을 사용해서 접근하는데, TS에서 Optional Property는 정의된 타입이거나 undefined,null 타입일 수도 있다고 예상하기 때문에 아래의 예시 코드에서 len 변수의 타입이 number로 정의되었기 때문에 오류가 발생하는 것이다. 따라서 Null이 아니라는 뜻을 가진 Non Null 연산자인 "!"를 붙여주어 Non Null 단언을 통해 오류 발생을 막아줄 수 있다.
type Post = {
title: string,
author?: string
}
let post:Post = {
title: "Hello",
author: "Bob"
}
const len:number = post.author?.length; //Error, post.author.length can be a undefined type
const len:number = post.author!.length; //Not Error by Non Null
[타입 좁히기]
타입 좁히기
- 조건문 등을 이용해 넓은 타입에서 좁은 타입으로 타입을 상황에 따라 좁히는 방법. 임의의 함수에서 매개 변수의 타입이 여러 가지이고, 그 매개 변수를 통해 다양한 조건 처리를 할 때 유용하다. 타입을 좁히기 위해 사용하는 조건문,조건을 흔히 Guard라고 부른다.
조건문의 조건에 typeof 연산자를 사용하여 변수의 타입에 대한 조건을 설정하면 Union Type으로 정의된 변수의 타입의 범위가 좁혀져 특정 타입으로 여겨지게 된다.
function func(value: number | string){
if(typeof value === "number"){
console.log(value.toFixed()); //type of value === number
}else if(typeof value === "string"){
console.log(value.toUpperCase()); //type of value === string
}
}
Union Type으로 정의된 변수의 타입에 객체가 들어가는 경우는 어떨까
function func(value: null | Date){
if(typeof value === "object"){
console.log(value.getTime()); //Error, null type is also object type in JS
}
if(value instanceof Date){
console.log(value.getTime()); //Not Error
}
}
JS에 내장 되어있는 class 객체의 경우에는 매개 변수의 타입을 instanceof 연산자를 통해 처리한다. instanceof 연산자의 뒤에는 타입이 들어가서는 안되며 객체가 들어가야 한다.
타입 별칭을 통해 임의로 생성한 객체 type의 경우에는 매개 변수 타입을 && 연산자와 in 연산자를 합해서 사용한다.
type Person = {
name: string,
age: number
}
function func(value: Person){
if(value instaceof Person){
console.log(value.name); //Error, Person is type, not a class object
}
if(value && "age" in value){
console.log(value.age); //Not Error
}
}
in 연산자를 통해서 변수 내부에 특정 속성이 있는지 여부를 확인하였지만 변수의 값이 undefined이거나 null이면 안되기 때문에 && 연산자를 통해서 undefined,null 값인 경우를 배제하여 조건 처리를 하는 것이다.
[서로소 유니온 타입]
서로소 유니온 타입
- 교집합이 없는 타입들로만 만든 유니온 타입을 말한다. 같은 속성을 가진 객체 타입들이 존재할 때 각 타입별로 공통된 부분을 없애서 데이터 처리를 하기에 유용한 타입이다. 코드를 직접 보는 것이 가장 이해가 쉬울 것 같다.
type Admin ={
name: string,
kickCount: number
}
type Member = {
name: string,
point: number
}
type Guest = {
name: string,
visitCount: number;
}
type User = Admin | Member | Guest;
function func(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{
//Guset
console.log(`${user.name}님 현재까지 ${user.visitCount}번 방문했습니다.`);
}
}
위의 코드에서 func 함수를 보았을 때 내부에 조건문들에 in 연산자를 통해서 타입 추론에 의거해 타입 좁히기 방식이 사용되어 매개변수가 특정 속성을 갖고 있다면 해당 타입으로 인지를 하고, 그에 맞는 명령을 실행하도록 되어있다. 하지만 위와 같이 주석을 달아놓지 않거나 타입 추론과 타입 좁히기를 통해 알게된 해당 타입과 내부 속성을 일일이 찾아보기에는 효율성이 떨어지며 코드의 직관성이 떨어진다. 이는 코드 자체의 오류는 발생하지 않지만 협업 시에 굉장한 불편함을 초래한다.
따라서 서로소 유니온 타입을 사용하여 코드를 다음과 같이 개선할 수 있다.
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;
function login1(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;
}
}
}
Type Alias로 정의한 각 타입들 내부에 tag라는 속성은 String Literal Type으로 정의되었기에 다른 타입들 간의 교집합이 존재할 수 없다. 따라서 더 직관적으로 알아보기 쉽고 타입별로 확실하게 구분을 지을 수 있기에 로그인 유효성 검사 같은 기능에서 유용하게 사용할 수 있다.
동시에 여러가지 상태를 표현하는 객체의 타입을 정의할 때에는 Optional Property를 사용하는 것 보다 각 상태에 따른 타입을 각각 정의를 하여 서로소 유니온 타입으로 정의하여 사용하는 것이 더 좋다. 이것 또한 코드를 보며 직관적인 이해를 해보도록 하자.
type AsyncTask = {
state: "LOADING" | "FAILED" | "SUCCESS",
error?: {
message: string
},
response?: {
data: string
}
}
function processResult(task: AsyncTask){
switch(task.state){
case "LOADING": {
console.log("Now Loading..")
break;
}
case "FAILED":{
console.log(`error: ${task.error?.message}`); // Error
break;
}
case "SUCCESS":{
console.log(`success: ${task.response!.data}`); // Error
break;
}
}
}
위의 코드는 데이터 통신 결과(상태)에 따른 조건 처리를 나타낸 코드이다. state 값이 FAILED일 경우 error 객체에 접근하여 message를 출력하고, SUCCESS인 경우 response 객체에 접근하여 data 값을 출력해주어야 한다. 하지만 AsyncTask 타입에 error 속성과 response 속성은 Optional Property로 정의되어 undefined 값일 경우가 있기에 Optional Chaining이나 Non Null 연산 처리를 해주지 않으면 오류가 발생한다.
이런 번거로운 경우를 피하기 위해 코드를 다음과 같이 개선할 수 있다.
type LoadingTask = {
state: "LOADING"
};
type FailedTask = {
state: "FAILED"
error: {
message: string
}
};
type SuccessTask = {
state: "SUCCESS"
response: {
data: string
}
};
type AsyncTask = LoadingTask | FailedTask | SuccessTask;
function processResult(task: AsyncTask){
switch(task.state){
case "LOADING": {
console.log("Now Loading..")
break;
}
case "FAILED":{
console.log(`error: ${task.error.message}`);
break;
}
case "SUCCESS":{
console.log(`success: ${task.response.data}`);
break;
}
}
}
각 state에 따른 타입을 별도로 정의를 하여 서로소 유니온 타입으로 분리를 하면 각 상태 값에 따라서 속성 값들에 대해 쉽고 더 직관적으로 접근이 가능하다.
이렇게 비슷한 기능을 하며 같은 속성을 갖고 있는 서로 다른 타입들을 통해 기능을 구현할 때, 각 타입별로 겹치거나 객체의 속성값들에 대한 추가 처리를 해 줄 필요가 없게 만들어주는 타입 및 방식이라고 생각하면 이해가 쉬울 것 같다.
'Typescript' 카테고리의 다른 글
[TypeScript] - (6) Class (0) | 2024.08.05 |
---|---|
[Typescript] - (5) Interface (0) | 2024.08.01 |
[Typescript] - (4) Typescript 함수 (0) | 2024.07.31 |
[TypeScript] - (2) Typescript 기본 (3) | 2024.07.23 |
[Typescript] - (1) Typescript 사용하기 (2) | 2024.07.19 |