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

Doozuu·2023년 10월 2일
0

TypeScript

목록 보기
7/13

📌 타입스크립트 이해하기

타입스크립트를 이해한다는 말은 타입스크립트가 어떤 기준으로 타입을 정의하고, 어떤 기준으로 타입들간의 관계를 정의하고, 어떤 기준으로 타입스크립트 코드의 오류를 검사 하는지 그 원리와 동작 방식을 낯낯이 살펴본다는 말이다.

아래에 있는 타입스크립트 공식 문서에서 주요 문법들만 모아둔 치트시트를 제공하기도 하지만, 문법만 외우고 원리에 대한 이해가 없다면 다양한 상황에서 사용하기 어렵다.

https://www.typescriptlang.org/cheatsheets



📌 타입은 집합이다

타입스크립트의 타입은 사실 여러개의 값을 포함하는 집합이다.

집합은 동일한 속성을 갖는 여러개의 요소들을 하나의 그룹으로 묶은 단위를 말한다.

따라서 다음 그림처럼 여러개의 숫자 값들을 묶어 놓은 집합을 타입스크립트에서는 number 타입이라고 부른다.

그렇다면 오직 하나의 값만 포함하는 타입인 Number Literal 타입은 어떤 집합일까?

예를 들어 20 이라는 Number Literal 타입이 존재한다면, 이 타입은 다음 그림과 같이 딱 하나의 값만 포함하는 아주 작은 집합이라고 볼 수 있다.

20이라는 타입은 숫자이기 때문에 Number 타입이라는 거대한 집합에도 속한다.
결국 모든 Number Literal 타입은 Number 타입이라는 거대한 집합에 포함되는 부분 집합으로 볼 수 있다.

사실 타입스크립트의 모든 타입들은 집합으로써 서로 포함하고 또 포함되는 이런 관계를 갖는다.

그리고 이런 관계에서 Number 타입처럼 다른 타입을 포함하는 타입을 슈퍼 타입(부모 타입)이라고 부릅니다. 반대는 서브 타입(자식 타입)이라고 한다.

이 관계를 계층처럼 표시하면 다음과 같은 그림이 된다.

이를 바탕으로 타입 계층도를 나타내면 아래와 같이 그릴 수 있다.


타입 호환성

타입 호환성이란 예를 들어 A와 B 두개의 타입이 존재할 때 A 타입의 값을 B 타입으로 취급해도 괜찮은지 판단하는 것을 의미한다.

그래서 만약 A 타입의 값이 B 타입의 값으로 취급 되어도 괜찮다면 "호환된다"고 하고 안된다면 "호환되지 않는다"고 한다.

예를 들어 다음 그림처럼 Number 타입과 Number Literal 타입이 있을 때, 서브 타입인 Number Literal 타입의 값을 슈퍼 타입인 Number 타입의 값으로 취급하는 것은 가능하다. 그러나 반대로는 불가능하다.

let num1: number = 10;
let num2: 10 = 10;

num1 = num2; // 다음과 같은 코드는 문제가 되지 않는다.
num2 = num1; // 이건 문제가 된다.
// 큰 그릇에 작은 그릇을 담을 순 있지만 작은 그릇에 큰 그릇을 담을 순 없는 느낌!

따라서 타입스크립트에서는 이렇게 슈퍼타입의 값을 서브타입의 값으로 취급하는것을 허용하지 않는다. (반대로는 허용한다.)

그리고 특별히 서브 타입의 값을 슈퍼 타입의 값으로 취급하는 것은 업 캐스팅 이라고 부르고 반대는 다운 캐스팅이라고 부른다.
따라서 쉽게 정리하면 업 캐스팅은 모든 상황에 가능하지만, 다운 캐스팅은 대부분의 상황에 불가능하다고 할 수 있다.



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

unknown 타입 (전체 집합)

unknown 타입은 타입 계층도의 최상단에 위치한다.

따라서 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 타입의 값은 any를 제외한 어떤 타입의 변수에도 할당할 수 없다.

let unknownValue: unknown;

let a: number = unknownValue;
// 오류 : unknown 타입은 number 타입에 할당할 수 없습니다.

never 타입 (공집합)

never 타입은 타입 계층도에서 가장 아래에 위치한다.

공집합은 모든 집합의 부분 집합이기 때문에 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

반면에 그 어떤 타입도 never 타입으로 다운 캐스팅 할 수 없다.

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 ❌

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)

any 타입 (치트키)

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 (업 캐스트)



📌 객체 타입의 호환성

모든 객체 타입은 각각 다른 객체 타입들과 슈퍼-서브 타입 관계를 갖는다.
따라서 업 캐스팅은 허용하고 다운 캐스팅은 허용하지 않는다.

다음 예제에서 Animal 타입의 변수 animal에 Dog 타입의 변수 dog를 할당하는 것은 가능하다.
그러나 반대로 dog 변수에 animal 변수의 값을 할당하는 것은 불가능하다.
Animal 타입이 Dog 타입의 슈퍼타입이기 때문이다.

Animal 타입이 Dog 타입의 슈퍼타입인 이유는 어떤 객체가 Dog 타입에 포함된다면 무조건 Animal 타입에도 포함되지만, Animal 타입에 포함되는 모든 객체가 Dog 타입에 포함되는것은 아니기 때문이다.

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

초과 프로퍼티 검사

다음과 같이 초기값을 설정하면 오류가 발생한다.

type Book = {
  name: string;
  price: number;
};

type ProgrammingBook = {
  name: string;
  price: number;
  skill: string;
};

(...)

let book2: Book = { // 오류 발생
  name: "한 입 크기로 잘라먹는 리액트",
  price: 33000,
  skill: "reactjs",
};

Book이 ProgrammingBook의 슈퍼 타입이기 때문에 업 캐스팅에 해당하는데 오류가 발생한 이유는 ‘초과 프로퍼티 검사’ 때문이다.

초과 프로퍼티 검사란 변수를 객체 리터럴로 초기화 할 때 발동하는 타입스크립트의 특수한 기능이다.

이 기능은 타입에 정의된 프로퍼티 외의 다른 초과된 프로퍼티를 갖는 객체를 변수에 할당할 수 없도록 막는다.

이런 초과 프로퍼티 검사는 단순히 변수를 초기화 할 때 객체 리터럴을 사용하지만 않으면 발생하지 않는다.

따라서 다음과 같이 값을 별도의 다른 변수에 보관한 다음, 변수 값을 초기화 값으로 사용하면 발생하지 않는다.

type Book = {
  name: string;
  price: number;
};

type ProgrammingBook = {
  name: string;
  price: number;
  skill: string;
};

let programmingBook: ProgrammingBook = {
  name: "한 입 크기로 잘라먹는 리액트",
  price: 33000,
  skill: "reactjs",
};

let book3: Book = programmingBook; // 앞서 만들어둔 변수



📌 대수 타입

대수 타입(Algebraic type)이란

대수 타입이란 여러개의 타입을 합성해서 만드는 타입을 말한다.

  • 대수 타입에는 합집합(Union) 타입과 교집합(Intersection) 타입이 존재한다.

합집합(Union) 타입

다음과 같이 바를 이용해 string과 number의 유니온 타입을 정의할 수 있다.

// 합집합 타입 - Union 타입
let a: string | number;

// 이제 변수 a에는 number 타입과 string 타입에 해당하는 값이라면 뭐든 저장할 수 있다.
a = 1;
a = "hello";

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;

이렇게 정의된 Union1 타입은 교집합이 존재하는 두 집합으로 표현할 수 있다.

(...)

let union1: Union1 = { // ✅
  name: "",
  color: "",
};

let union2: Union1 = { // ✅
  name: "",
  language: "",
};

let union3: Union1 = { // ✅
  name: "",
  color: "",
  language: "",
};

let union4: Union1 = { // ❌
  name: "",
};

교집합(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: "",
};



📌 타입 추론

타입스크립트는 타입이 정의되어 있지 않은 변수의 타입을 자동으로 추론하는데, 이런 기능을 “타입 추론”이라고 한다.

let a = 10;
// number 타입으로 추론

그러나 모든 상황에 타입을 잘 추론하는 것은 아니다. 예를 들어 다음과 같이 함수의 매개변수 타입은 자동으로 추론할 수 없다.

function func(param){ // 오류

}

그리고 이렇게 타입 추론이 불가능한 변수(ex 매개변수)에는 암시적으로 any 타입이 추론된다. 그러나 엄격한 타입 검사 모드 (tsconfig.json의 strict 옵션을 true로 설정)에서는 이런 암시적 any 타입의 추론을 오류로 판단하게 된다.

타입 추론이 가능한 상황들

  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 프로퍼티가 있는 객체 타입으로 추론
  1. 구조 분해 할당
    객체와 배열을 구조 분해 할당하는 상황에서도 타입이 잘 추론된다.
let { id, name, profile } = c;

let [one, two, three] = [1, "hello", true];
  1. 함수의 반환값
    함수 반환값의 타입은 return 문을 기준으로 잘 추론된다.
function func() {
  return "hello";
}
// 반환값이 string 타입으로 추론된다
  1. 기본값이 설정된 매개변수
    기본값이 설정된 매개변수의 타입은 기본값을 기준으로 추론된다.
function func(message = "hello") {
  return "hello";
}

주의해야 할 상황들

  1. 암시적으로 any 타입으로 추론
    변수를 선언할때 초기값을 생략하면 암시적인 any 타입으로 추론된다.

참고로 이때 매개변수의 타입이 암시적 any로 추론될 때와 달리 일반 변수의 타입이 암시적 any 타입으로 추론되는 상황은 오류로 판단하지 않는다.

let d;
// 암시적인 any 타입으로 추론

그리고 이 변수에 값을 할당하면 그 다음 라인부터 any 타입이 해당 값의 타입으로 변화한다.
이렇게 암시적으로 추론된 any 타입은 코드의 흐름에 따라 타입이 계속 변화하는데, 이를 any의 진화라고 표현하기도 한다.

let d;
d = 10;
d.toFixed();

d = "hello";
d.toUpperCase();
d.toFixed(); // 오류 
  1. const 상수의 추론
    const로 선언된 상수도 타입 추론이 진행된다.
    그러나 let으로 선언한 변수와는 다른 방식으로 추론된다.

상수는 초기화 때 설정한 값을 변경할 수 없기 때문에 특별히 가장 좁은 타입으로 추론된다.

let num1 = 10;
// number 타입으로 추론

const num2 = 10;
// 10 Number Literal 타입으로 추론

최적 공통 타입(Best Common Type)

다음과 같이 다양한 타입의 요소를 담은 배열을 변수의 초기값으로 설정하면, 최적의 공통 타입으로 추론된다.

let arr = [1, "string"];
// (string | number)[] 타입으로 추론



📌 타입 단언

다음의 예제에서 변수 person은 Person 타입으로 정의되었지만 초기화 할 때에는 빈 객체를 넣어두고 싶다고 가정할 때, 타입스크립트에서는 이런 경우를 허용하지 않기 때문에 오류가 발생한다.(빈 객체는 Person 타입이 아니므로)

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

let person: Person = {};
person.name = "";
person.age = 23;

이럴 땐 다음과 같이 이 빈 객체를 Person 타입이라고 타입스크립트에게 단언해주면 된다.

이렇게 값 as 타입 으로 특정 값을 원하는 타입으로 단언할 수 있다.
이를 타입 단언 이라고 부른다.

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

let person = {} as Person;
person.name = "";
person.age = 23; 

타입 단언은 다음과 같이 초과 프로퍼티 검사를 피할때에도 요긴하게 사용할 수 있다.

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 단언

Non Null 단언은 지금까지 살펴본 값 as 타입 형태를 따르지 않는 단언이다.
값 뒤에 느낌표(!) 를 붙여주면 이 값이 undefined이거나 null이 아닐것으로 단언할 수 있다.

type Post = {
  title: string;
  author?: string;
};

let post: Post = {
  title: "게시글1",
};

const len: number = post.author!.length;



📌 타입 좁히기

매개변수 value의 타입이 number | string 이므로 함수 내부에서 다음과 같이 value가 number 타입이거나 string 타입일 것으로 기대하고 메서드를 사용하려고 하면 오류가 발생한다.

function func(value: number | string) {
  value.toFixed() // 오류
	value.toUpperCase() // 오류
}

만약 value가 number 타입일거라고 기대하고 toFixed 메서드를 사용하고 싶다면 다음과 같이 조건문을 이용해 value의 타입이 number 타입임을 보장해줘야 한다.

또한 value가 string 타입일거라고 기대하고 toUpperCase 메서드를 사용하고 싶다면 다음과 같이 조건문을 이용해 value의 타입이 string 타입임을 보장해 주어야 한다.

function func(value: number | string) {
  if (typeof value === "number") {
    console.log(value.toFixed());
  } else if (typeof value === "string") {
    console.log(value.toUpperCase());
  }
}

이렇게 조건문을 이용해 조건문 내부에서 변수가 특정 타입임을 보장하면 해당 조건문 내부에서는 변수의 타입이 보장된 타입으로 좁혀진다. 이를 타입 좁히기 라고 표현한다.


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());
  }
}

그러나 Instanceof는 내장 클래스 또는 직접 만든 클래스에만 사용이 가능한 연산이다. 따라서 우리가 직접 만든 타입과 함께 사용할 수 없다.

in 타입 가드

우리가 직접 만든 타입과 함께 사용하려면 다음과 같이 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}살 입니다`)
  }
}



📌 서로소 유니온 타입

서로소 유니온 타입은 교집합이 없는 타입들 즉, 서로소 관계에 있는 타입들을 모아 만든 유니온 타입을 말한다.

다음과 같은 간단한 회원 관리 프로그램이 있다고 가정하자.

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}번 오셨습니다`);
  }
}

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;
};

(...)

그럼 이제 login 함수의 타입가드를 다음과 같이 더 직관적으로 수정할 수 있게 된다.

(...)

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}번 오셨습니다`);
  }
}

또는 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;
    }
  }
}
profile
모든게 새롭고 재밌는 프론트엔드 새싹

0개의 댓글