Notice
Recent Posts
Recent Comments
Link
관리 메뉴

윤일무이

[TypeScript] 타입스크립트 이해하기 본문

❔ TypeScript/💭 한 입 크기 TypeScript

[TypeScript] 타입스크립트 이해하기

썸머몽 2023. 12. 18. 03:40
728x90
  • 타입스크립트는 어떤 기준으로 타입을 정의하는가?
  • 타입 스크립트는 어떤 기준으로 타입 간의 관계를 정의하는가?
  • 타입 스크립트는 어떤 기준으로 타입의 오류를 검사하는가?

타입 스크립트 문법만 정리된 Cheat Sheets도 있지만 원리를 이해해야 정확히 사용할 수 있다.


타입은 집합이다

  • 동일한 속성과 특징을 갖는 여러 개의 값들을 모아둔 집합

  • let num: 20 = 20; 의 20은 넘버 리터럴 타입이지만 동시에 넘버 타입에 속하기도 한다.
  • 이와 같이 타입들끼리 어떤 계층을 갖게 된다.

  • 타입 간의 호환성은 어떤 타입을 다른 타입으로 취급해도 괜찮은지 판단하는 것
  • 넘버 리터럴 타입의 값인 20을 넘버 타입의 값으로 취급해도 되지만, 반대는 불가능하다. (ex. 직사각형과 정사각형의 관계)
let num1: number = 10;
let num2: 10 = 10;

num1 = num2; // 가능
num2 = num1; // 불가능

 

  • 슈퍼 타입(부모 타입)의 값을 서브 타입(자식 타입)의 값으로 보는 것을 다운 캐스팅이라고 한다. (대부분 불가능)
  • 서브 타입(자식 타입)의 값을 슈퍼 타입(부모 타입)의 값으로 보는 것을 업 캐스팅이라고 한다. (모든 상황에 가능)

타입 계층도와 함께 기본 타입 살펴보기

// Unknown 타입 (전체 집합)
function unknownExam() {
    // 모든 타입이 업 캐스팅 될 수 있음
    let a: unknown = 1;
    let b: unknown = "hello";
    let c: unknown = true;
    let d: unknown = null;
    let e: unknown = undefined; 

    // 다운 캐스팅은 안됨
    // let unknownVar: unknown;
    // let num: number = unknownVar; 
    // let str: string = unknownVar; 
    // let boo: boolean = unknownVar;
}
  • unknown 타입은 모든 타입의 전체 집합 격으로, 모든 타입이 unknown 타입으로 업 캐스팅 될 수 있다.
  • 반면 unknown 타입이 서브 타입으로 다운 캐스팅 될 수는 없다.
// never 타입 (불가능, 모순을 의미 - 공집합 & 모든 집합의 부분 집합)
function neverExam() {

    // 반환값이 아무 것도 없다
    function neverFunc(): never {
        while (true) {}
    }

    // 업 캐스팅
    let num: number = neverFunc();
    let str: string = neverFunc();
    let boo: boolean = neverFunc();

    // 다운 캐스팅은 안됨
    // let never1: never = 10;
    // let never2: never = 'string';
    // let never3: never = true;
}
  • 모든 집합의 부분 집합, 가장 아래의 서브 집합인 never는 불가능, 모순을 뜻하며 반환값이 존재할 수 없는 경우 사용한다.
  • 가장 아래의 서브 집합이기 때문에 위의 집합인 number, string 등 다른 슈퍼 집합으로 업 캐스팅이 가능하다.
  • 하지만 슈퍼 집합이 never로 다운 캐스팅될 수는 없다.
// void 타입 (반환값이 없는 경우. undefined의 슈퍼 타입임)
function voidExam() {
    function voidFunc(): void {
        console.log('hi');
        // return undefined (가능)
    }

    let voidVar: void = undefined;
}
  • 반환값이 존재할 수 없는 never와 달리 void는 그냥 '없는' 경우를 의미한다.
  • undefined의 슈퍼 타입이기 때문에 undefined가 void로 업 캐스팅 될 수 있다.
// any 타입 (타입 계층 무시. 모든 타입의 슈퍼 타입이자 모든 타입의 서브 타입임(never 제외))
function anyExam() {
let unknownVar: unknown;
let anyVar: any;
let undefinedVar: undefined;
let neverVar: never;

anyVar = unknownVar; // any 타입에 한정해서 unknown 타입이 다운 캐스팅 됨
undefinedVar = anyVar; // any 타입은 자기한테 오는 다운 캐스팅, 자기가 다운 캐스팅도 됨
// neverVar = anyVar; // 이건 안됨 (never는 순수한 공집합이기 때문에 어떤 타입도 다운 캐스팅 안됨)

}
  • any 타입은 모든 타입의 슈퍼 타입이자 모든 타입의 서브 타입이다. (never 제외)
  • 단, any 타입에 한정해서 unknown 타입이 다운 캐스팅 될 수 있다.
  • 단, never 타입으로는 어떤 타입도 다운 캐스팅 될 수 없기 때문에 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;
// dog = animal; // Property 'breed' is missing in type 'Animal' but required in type 'Dog'.
  • 객체는 프로퍼티를 기준으로 계층 기준을 갖는다.
  • 동일한 프로퍼티를 가졌다고 할 때 조건이 더 적은 쪽이 슈퍼 타입(부모 타입)이 된다.
let animal2: Animal = {
    name: '몽구',
    color: 'black',
    // breed: '포메라니안'
};

let animal3: Animal = dog;
function func(animal: Animal) {}
func({
    name: '몽구',
    color: 'black',
    // breed: '포메라니안'
})

func(dog);
  • 변수를 초기화 할 때 객체 리터럴을 쓰면 발동되는 초과 프로퍼티 검사
  • 실제 타입에 정의하지 않은 초과 프로퍼티를 적어 놓으면 에러가 발생한다.
  • 객체 리터럴로 초기화 한다면 실제 타입과 동일하게 프로퍼티를 설정해야 한다.
  • 함수의 매개변수로 추가할 때에도 에러가 발생하는 점은 동일하다.

대수 타입 (Algebraic Data Type = ADT)

  • 여러 개의 타입을 합성해서 새롭게 만들어낸 타입
  • 합집합, 교집합 타입
// 합집합 (Union)
let a: string | number;
a = 10;
a = 'a';

let arr: (number | string | boolean)[] = [1, "hello", true]
  • string number union type
  • 숫자형, 문자형 타입의 값을 할당 받을 수 있는 합집합 타입
  • |으로 원하는 만큼 타입을 추가할 수 있다.
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 타입과 Person 타입을 선언한 후 그 둘의 합집합인 Union1 타입을 선언했다.
  • Union1 타입에 부합할 수 있는 경우는 union1, union2, union3으로 union4는 부합하지 않는다.
// 교집합 (Intersection)
let variable: number & string;
type Intersection = Dog & Person;
  • 단순 변수의 경우 number와 string이 교집합이 될 수 없으므로 never가 된다.
  • 따라서 주로 객체에서 많이 사용하는데, Dog 타입과 Person 타입의 교집합으로 Intersection 타입을 선언한다.
let intersection1: Intersection = {
    name: "",
    color: "",
    language: ""
}
  • Intersection 타입의 변수 intersection1은 Dog 타입과 Person 타입의 프로퍼티를 모두 가져야 한다.

타입 추론 (Type Inference)

  • 점진적 타입 추론: 변수의 초기값으로 변수의 타입을 추론하는 타입 스크립트의 시스템
  • 객체나 배열이 구조 분해 할당이 되어도, 객체 안에 여러 타입의 값이 있어도 자동으로 타입을 추론해준다.
  • 하지만 함수를 정의할 때 함수 파라미터의 타입을 지정해주지 않으면 에러가 발생한다. (어떤 타입의 값이 들어오는지 모름)
  • 단, 파라미터의 초기값이 있으면 그 초기값을 기준으로 추론한다.
let d;
d = 10;
d.toFixed();

d = "hi";
d.toUpperCase();
// d.toFixed(); // 에러 발생
  • let d는 초기값이 없으므로 암묵적으로 타입을 any로 추론한다.
  • 변수 d에 들어가는 값에 따라 any가 다른 타입으로 진화하는 것을 any 타입의 진화라고 한다.
  • 명시적으로 let d: any를 하는 것과는 다름 (타입이 계속 진화하므로 d.toFixed() 같은 이전 타입은 쓸 수 없음)
  • 다만 이렇게 타입이 계속 진화하면 혼란의 여지가 있다.
const num = 10;
const str = 'hi';
  • let이 아닌 const로 선언한 num의 타입은 number가 아니라 넘버 리터럴 10이 된다. (상수이기 때문에)
  • str의 타입도 단순 string이 아니라 'hi'를 타입으로 갖는 스트링 리터럴이 된다.
  • const로 선언된 경우가 아니라면 타입 스크립트는 리터럴이 아닌 number, string처럼 범용적으로 타입을 추론한다. (타입 넓히기)

타입 단언

type Person = {
  name: string;
  age: number;
};

let person = {} as Person;
person.name = 'summermong';
person.age = 27;
  • 변수 person을 Person 타입의 값으로 하고 싶지만 처음엔 {}으로 선언한 뒤 속성을 추가하고 싶은 경우가 있다고 하자.
  • let person: Person = {} 으로 코드를 작성하면 Person 타입의 값이 아니라고 에러가 발생한다.
  • let person = {}으로 초기화를 하면 이것도 이것대로 객체 자체에는 name, age라는 속성이 없기 때문에 또 에러가 발생한다.
  • 따라서 {} 이지만 Person이 될 것이라고 단언하는 것을 타입 단언(Type Assertion)이라고 한다.
  • Person 타입으로 단언된 초기화 값으로 타입이 추론되기 때문에 person은 Person 타입으로 추론된다. 
type Dog = {
    name: string;
    color: string;
};

let dog = {
    name: '주몽',
    color: 'brown',
    breed: '시츄'
} as Dog;
  • 변수 dog의 타입을 Dog로 설정하면 breed 때문에 초과 프로퍼티 검사에 걸리게 된다.
  • 이 때 as Dog를 작성해 Dog 타입으로 단언해주면 에러가 발생하지 않는다.
let num1 = 10 as never; // number는 never의 슈퍼 타입
let num2 = 10 as unknown; // number는 unknown의 서브 타입
// let num3 = 10 as string; // number는 string의 슈퍼/서브 타입이 아님
let num3 = 10 as unknown as string; // 다중단언으로 가능함
  • 단, 타입 선언에는 규칙이 있다.
  • 값 as 단언 일 때, 값은 단언하는 것의 슈퍼 타입 또는 서브 타입이어야 한다.
  • 다중단언으로 서로소와 같은 타입이어도 타입을 단언할 수는 있으나 지양하는 것이 좋다.
let num4 = 10 as const;
  • as const를 작성하지 않을 때 num4의 타입은 number로 추론된다.
  • const로 타입을 단언하면 넘버 리터럴 타입 10으로 추론된다. (=const로 선언한 것과 동일한 효과)
let cat = {
    name: '야옹이',
    color: 'yellow'
} as const;

cat.name = '' // readonly
  • const 단언은 객체와 사용하면 객체의 속성을 수정할 수 없도록 readonly 키워드를 붙여준다.
  • 일일이 readonly 키워드를 붙일 필요가 없고, 속성이 많아 변경될 위험이 있는 객체의 경우도 안전하게 속성값을 보존할 수 있다.
type Post = {
  title: string;
  author?: string;
};

let post: Post = {
  title: '1',
  author: 'summermong',
};

const len: number = post.author.length;
// const len: number = post?.author.length; // 옵셔널 체이닝

const len: number = post.author!.length;
  • 어떤 값이 undefined거나 null이 아니라고 단언하는 Non Null 단언
  • author가 string일수도 있고 없을 수도 있을 때, length와 같이 점 표기법으로 접근할 때 자동으로 ?.(옵셔널 체이닝)이 생긴다.
  • 옵셔널 체이닝이란 접근한 프로퍼티의 값이 undefined거나 null일 때 undefined로 바꿔주는 역할을 한다.
  • 따라서 이 값이 undefined나 null이 아니라 string이라고 단언하기 위해서 !.(단언 연산자)을 붙여주면 된다.
  • 단언은 업 캐스팅이나 다운 캐스팅처럼 실제로 바꾸는 것이 아니라 잠깐 컴파일러의 눈을 가린 것으로 사용 시 주의해야 한다.

타입 좁히기

function func(value: number | string) {
    value; // number | string
    // value.toUpperCase();
    // value.toFixed();

    if (typeof value === 'number') {
        console.log(value.toFixed()); // number
    } else if (typeof value === 'string') {
        console.log(value.toUpperCase()); // string
    }
}
  • 조건문 등을 이용해 타입을 상황에 따라 좁히는 방법을 타입 좁히기(Type Narrowing)라고 한다.
  • func의 매개변수인 value는 number 또는 string이기 때문에 조건문 밖에서는 number, string 메서드를 쓸 수 없다.
  • 하지만 조건문으로 value의 타입이 number, 또는 string이 될 때는 타입 스크립트가 타입을 좁혀 판단해 에러가 발생하지 않는다.
  • 조건문의 중괄호에서 타입을 좁힐 수 있는데 이렇게 타입을 지키는 부분을 타입 가드(Type Guard)라고 한다.
function func(value: number | string | Date | null) {
    if (typeof value === 'number') {
        console.log(value.toFixed()); // number
    } else if (typeof value === 'string') {
        console.log(value.toUpperCase()); // string
    } else if (typeof value === 'object') { 
        console.log(value.getTime()); // object를 좀 더 좁혀야함
    }
}
  • Date 객체를 단순히 object로 좁히면 null에서 에러가 발생한다. (typeof null은 객체를 반환한다.)
  • 따라서 아래와 같이 instanceof 연산자로 value는 Date 클래스의 인스턴스인지 확인하는 방식으로 수정할 수 있다.
type Person = {
  name: string;
  age: number;
};

function func(value: number | string | Date | null | Person) {
  if (typeof value === 'number') {
    console.log(value.toFixed()); // number
  } else if (typeof value === 'string') {
    console.log(value.toUpperCase()); // string
  } else if (value instanceof Date) {
    console.log(value.getTime()); // object를 좀 더 좁혀야함 null도 통과함
  } else if (value && 'age' in value) {
    console.log(`${value.name}은 ${value.age}살입니다.`)
  }
}
  • value가 Person 타입의 값이라고 할 때, value instanceof Person을 사용하면 에러가 발생한다.
  • instanceof는 우측에 작성된 것이 해당 클래스의 '인스턴스'인지 확인하는 연산자다.
  • Person은 그저 타입일 뿐이지 클래스의 인스턴스가 아니기 때문에 사용할 수 없다.
  • 이런 경우에는 in으로 해당 프로퍼티를 갖고 있는지 확인할 수 있다. (age 프로퍼티를 value가 가지고 있는지)
  • 다만 이 때 가지고 있지 않다, 즉 null일 수 있기 때문에 value가 있다고 조건을 하나 더 추가해주면 된다.

서로소 유니온 타입 (Taged Union Type)

type Admin = {
  name: string;
  kickCount: number;
};

type Member = {
  name: string;
  point: number;
};

type Guest = {
  name: string;
  visitCount: number;
};

type User = Admin | Member | Guest;
  • name만 겹치고 나머지 프로퍼티는 다른 세 가지 타입이 있다고 할 때, User가 어떤 타입인지 확인해 로그인을 하려고 한다.
function login(user: User) {
  if ('kickCount' in user) {
    console.log(`${user.name}님 현재까지 ${user.kickCount}명 강퇴했습니다.`);
  } else if ('point' in user) {
    console.log(`${user.name}님 현재까지 ${user.point}를 모았습니다.`);
  } else {
    console.log(`${user.name}님 현재까지 ${user.visitCount}번 방문했습니다.`);
  }
}
  • 가장 처음엔 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;
};
  • 이 때 tag 프로퍼티로 각각의 타입을 스트링 리터럴로 적어주면 세 가지 타입을 묶은 User는 서로소 유니온이 된다.
function login(user: User) {
    if (user.tag === 'ADMIN') {
      console.log(`${user.name}님 현재까지 ${user.kickCount}명 강퇴했습니다.`);
    } else if (user.tag=== 'MEMBER') {
      console.log(`${user.name}님 현재까지 ${user.point}를 모았습니다.`);
    } else {
      console.log(`${user.name}님 현재까지 ${user.visitCount}번 방문했습니다.`);
    }
  }

  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}번 방문했습니다.`);
        }
    }
  }
  • 그렇다면 이렇게 좀 더 직관적으로 코드를 짤 수 있다.
// 비동기 작업의 결과를 처리하는 객체

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('로딩중');
      break;
    }
    case 'FAILED': {
      console.log(`에러 발생: ${task.error.message}`);
      break;
    }
    case 'SUCCESS': {
      console.log(`성공: ${task.response.data}`);
      break;
    }
  }
}

 **출처: 한 입 크기로 잘라먹는 타입스크립트 (인프런, 이정환 강사님)

728x90