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

Jeris·2023년 6월 7일
0

TypeScript

목록 보기
3/11

타입스크립트 이해하기

타입스크립트를 이해한다는 것은, 어떤 기준으로 타입을 정의하고, 타입들 간의 관계를 어떻게 설정하며, 어떤 기준으로 코드의 오류를 판단하는지 등, 타입스크립트의 내부 동작 원리와 그 기준들을 이해하는 것을 의미합니다.

참조: TypeScript: Cheat Sheets


타입은 집합이다

타입은 값들의 집합으로 볼 수 있습니다. 이 집합은 값들이 어떤 특성이나 조건을 만족하는지에 따라 정의됩니다.

타입스크립트에서, 타입들은 포함 관계를 통해 계층을 이룹니다. 이런 관계는 집합의 부분 집합(subset)포함 집합(superset) 관계로 이해할 수 있습니다. 예를 들어, 숫자 리터럴 타입(예: 1, 2, 3 등 특정 숫자)은 숫자 타입(number)의 부분 집합이라고 할 수 있습니다. 즉, 모든 숫자 리터럴 타입은 number 타입에 속하며, number 타입은 그러한 숫자 리터럴 타입들을 모두 포함하는 포합 집합입니다.

이러한 타입의 포함 관계는 업캐스팅(upcasting)다운캐스팅(downcasting)을 가능하게 합니다. 업캐스팅은 서브타입(부분 집합)의 값을 슈퍼타입(포함 집합)으로 취급하는 것을 의미하며, 대부분의 경우에 허용됩니다. 반면 다운캐스팅은 슈퍼타입의 값을 서브타입으로 취급하는 것을 의미하며, 이는 보통 타입을 좁혀나가는 과정에서 명시적으로 타입 체크를 수행해야 가능합니다.


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

타입스크립트의 기본 타입을 이해하는 데 도움이 되는 타입 계층도입니다. 이 계층도는 타입스크립트의 주요 타입들이 어떻게 다른 타입들과 관련되어 있는지를 시각적으로 보여줍니다.

unknown 타입

unknown 타입은 어떤 타입의 값이라도 할당할 수 있는 타입입니다. unknown 타입의 변수에는 모든 종류의 값이 들어갈 수 있으나, 그 값을 다른 타입의 변수에 할당하려고 하면 컴파일 오류가 발생합니다. 이는 unknown 타입이 모든 타입의 슈퍼타입이기 때문입니다.

function unknownExam() {
  let a: unknown = 1;
  let b: unknown = "hello";
  let c: unknown = true;
  let d: unknown = null;
  let e: unknown = undefined;

  // 'unknown' 타입의 변수는 'any'를 제외한 다른 타입의 변수에 할당할 수 없습니다.
  let unknownVar: unknown;
  
  let num: number = unknownVar; // ❌ No
  let str: string = unknownVar; // ❌ No
  let bool: boolean = unknownVar; // ❌ No
}

never 타입

never 타입은 절대 발생하지 않는 값의 타입입니다. 예를 들어, 절대로 반환되지 않는 함수의 반환 타입으로 never를 사용할 수 있습니다. 이 함수는 항상 예외를 던지거나, 무한 루프에 빠져서 정상적으로 종료되지 않기 때문에 실제로 반환값이 존재하지 않습니다.

function neverExam() {
  function neverFunc(): never {
    while (true) {}
  }

  // 'never' 타입은 모든 타입으로 업캐스팅 할 수 있습니다.
  let num: number = neverFunc(); // ✅ OK
  let str: string = neverFunc(); // ✅ OK
  let bool: boolean = neverFunc(); // ✅ OK

  // 어떤 타입의 값도 'never' 타입의 변수에 할당할 수 없습니다.
  let never1: never = 10; // ❌ No
  let never2: never = "string"; // ❌ No
  let never3: never = true; // ❌ No
}

void 타입

void 타입은 값이 없는 상태를 나타내는 타입입니다. 함수에서 반환값이 없을 때 사용하며, 보통 함수에서 아무런 값을 반환하지 않을 때 해당 함수의 반환 타입으로 void를 사용합니다.

function voidExam() {
  function voidFunc(): void {
    console.log("hi");
    // 이 함수는 아무런 값도 반환하지 않습니다.
  }
  
  let voidVar: void = undefined;
}

any 타입

any 타입은 모든 타입의 슈퍼 타입이기도 하고 never를 제외한 모든 타입의 서브 타입이기도 합니다.

let anyValue: any;

let num: number = anyValue; // any -> number
let str: string = anyValue; // any -> string
let bool: boolean = anyValue; // any -> boolean
let never: never = anyValue; // ❌ No: any -> never

anyValue = num;  // number -> any
anyValue = str;  // string -> any
anyValue = bool; // boolean -> any
anyValue = never; // never -> any


객체 타입의 호환성

타입스크립트에서 객체 타입의 호환성(compatibility)은 주로 속성의 존재와 그에 대한 타입을 기반으로 판단됩니다. 즉, 한 객체 타입이 다른 객체 타입에 할당될 수 있는지 여부는 해당 객체 타입이 갖는 속성들이 호환되는지에 달려 있습니다.

기본 타입 간의 호환성

기본 타입들 사이에서는 서로 할당이 가능합니다. 예를 들어, number 타입의 변수에는 숫자 리터럴 타입의 값을 할당할 수 있습니다.

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

num1 = num2; // ✅ OK

객체 타입 간의 호환성

객체 타입 간의 호환성은 해당 타입의 속성들의 유무와 각 속성의 타입에 따라 결정됩니다. 한 타입의 객체가 다른 타입의 변수에 할당될 수 있는지는 이들 속성과 타입을 토대로 판단됩니다.

속성이 더 많은 타입의 객체는 속성이 더 적은 타입의 변수에 할당될 수 있습니다. 이런 형태의 호환성은 up cast라고 표현됩니다. 이는 더 많은 속성을 가진 타입이 더 적은 속성을 가진 타입에 완전히 포함되기 때문에 문제가 없습니다.

속성이 더 적은 타입의 객체를 속성이 더 많은 타입의 변수에 할당하는 것은 허용되지 않습니다. 이는 down cast라고 불리는데, 이유는 더 적은 속성을 가진 타입의 객체가 더 많은 속성을 가진 타입으로 변환될 때, 존재하지 않는 속성에 대한 접근을 시도하게 될 수 있기 때문입니다. 이렇게 되면 예상치 못한 에러를 발생시킬 수 있습니다.

  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 (up cast)
dog = animal; // ❌ NO (down cast)

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 (up cast)
programmingBook = book; // ❌ NO (down cast)

초과 프로퍼티 검사

타입스크립트에서는 초과 프로퍼티 검사(excess property checks)를 제공하여, 객체가 특정 타입으로 선언된 변수에 할당될 때 해당 타입에 정의되지 않은 추가적인 속성이 있는지를 검사합니다. 이 기능은 타입 안정성을 높이고, 개발자의 실수를 방지하는데 도움을 줍니다.

하지만 이미 선언된 변수나 함수의 매개변수로 전달될 때에는 이러한 초과 프로퍼티 검사가 적용되지 않습니다.

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

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

let book3: Book = programmingBook; // ✅ OK

function func(book: Book) {}
func({
  name: "한 입 크기로 잘라먹는 리액트",
  price: 33000,
  skill: "reactjs", // ❌ NO
});

func(programmingBook); // ✅ OK

대수 타입

대수 타입은 여러 타입들을 조합하여 새로운 타입을 생성하는 방법을 의미하며, 주로 합집합 타입(union type)과 교집합 타입(intersection type)을 사용합니다.

합집합 타입은 여러 타입 중 하나의 타입이 될 수 있는 경우를 의미합니다. 즉, | 연산자를 사용하여 여러 타입 중 하나를 선택할 수 있게 합니다. 이를 통해 변수는 여러 가지 타입 중 하나의 타입을 가질 수 있게 됩니다.

let a: string | number | boolean;
a = 1; // ✅ OK
a = "hello"; // ✅ OK
a = true; // ✅ OK

let arr: (number | string | boolean)[] = [1, "hello", true]; // ✅ OK

합집합 타입은 또한 여러 객체 타입을 조합하여 사용할 수 있으며, 해당 객체 타입 중 하나의 형태를 가질 수 있습니다.

type Dog = {
  name: string;
  color: string;
};

type Person = {
  name: string;
  language: string;
};

type Union1 = Dog | Person;

let union1: Union1 = {
  name: "Fido",
  color: "brown",
};

let union2: Union1 = {
  name: "Alice",
  language: "English",
};

let union3: Union1 = {
  name: "Bob",
  color: "white", 
  language: "Spanish",
};

let union4: Union1 = {
  name: "", // ❌ NO: 필수 프로퍼티가 누락되었습니다.
};

반면에, 교집합 타입(intersection type)은 여러 타입이 모두 충족되어야 하는 경우를 의미합니다. 즉, & 연산자를 사용하여 모든 타입을 결합할 수 있습니다.

하지만, 서로 충돌하는 타입의 경우에는 교집합 타입을 적용하면 never 타입이 됩니다. never 타입은 변수에 어떠한 값도 할당할 수 없는 상황을 의미합니다.

let variable: number & string; // variable: never

교집합 타입은 여러 객체 타입을 조합하여 사용할 수 있으며, 이때 생성된 타입은 모든 객체 타입의 속성을 포함해야 합니다.

type Intersection = Dog & Person;

let intersection1: Intersection = {
  name: "Fido",
  color: "brown",
  language: "English",
};

타입 추론

타입스크립트의 타입 추론(type inference)은 명시적으로 타입을 선언하지 않아도 변수의 초기값에 따라 그 타입을 자동으로 결정하는 기능을 의미합니다.

// variable declaration
let a = 10; // a: number
let b = "hello"; // b: string
let c = {
  id: 1,
  name: "이정환",
  profile: {
    nickname: "winterlood",
  },
  urls: ["https://winterlood.com"],
};

// destructuring
let { id, name, profile } = c;

let [one, two, three] = [1, "hello", true];

// return value of function
function func() {
  return "hello"; // func(): string
}

// optional parameter
function func(message = "hello") {
  return message; // message: string, func(): string
}

변수가 선언되는 시점에 초기값을 할당하지 않으면, 해당 변수의 타입은 any로 추론됩니다. 이 경우에는 어떠한 타입의 값도 할당할 수 있지만, 특정 메서드를 사용할 때 타입 에러가 발생할 수 있습니다.

let d; // d: any
d = 10; // d: number
d.toFixed();
d.toUpperCase(); // ❌ NO

d = "hello"; // d: string
d.toUpperCase();
d.toFixed(); // ❌ NO

const num = 10; // num: 10
const str = "hello"; // str: "hello"

let arr = [1, "string"]; // arr: (string | number)[]

/**
* const type inference
*/

const num = 10; // num: 10
const str = "hello"; // str: "hello"

/**
* best common type inference
*/

let arr = [1, "string"]; // arr: (string | number)[]

타입 단언

타입 단언(Type Assertion)은 프로그래머가 더 정확한 타입 정보를 TypeScript 컴파일러에 제공하는 방법입니다. 즉, 프로그래머는 타입 추론보다 더 많은 정보를 가지고 있다는 것을 알려주는 것입니다. 타입 단언은 두 가지 방법으로 할 수 있는데, 꺾쇠<>를 사용하거나 as 키워드를 사용하는 것입니다.

그러나 주의할 점은, 타입 단언은 컴파일러에게 이 변수를 이 타입으로 처리하라고 지시하는 것이지, 실제로 변수 타입을 변경하거나 새로운 데이터를 생성하는 것은 아닙니다.

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

let person = {} as Person;
person.name = "이정환";
person.age = 27;

type Dog = {
  name: string;
  color: string;
};

let dog = {
  name: "돌돌이"
  color: "brown",
  bread: "진도", // ❌ NO
} as Dog;

타입 단언을 사용할 때, A를 B로 단언하려면 A는 B의 슈퍼타입이거나 서브타입이어야 합니다.

let num1 = 10 as never; // ✅ OK
let num2 = 10 as unknown; // ✅ OK

let num3 = 10 as string; // ❌ NO

const 단언을 사용하면 모든 프로퍼티를 readonly로 만들 수 있습니다.

let num4 = 10 as const;

let cat = {
  name: "야옹이",
  color: "yellow",
} as const;

cat.name = ''// ❌ NO

Non-null 단언은 특정 변수가 null 또는 undefined가 아니라는 것을 명시적으로 나타냅니다. 이는 컴파일러에게 이 변수가 확실히 값을 가지고 있음을 알려줍니다. 이를 사용할 때 주의가 필요하며, 이 값이 실제로 null 또는 undefined인 경우 런타임에서 오류가 발생할 수 있습니다.

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

let post: Post = {
  title: "게시글1",
  author: "이정환"
}

const len1: number = post.author?.length; // ❌ NO (optional chaining)
const len2: number = post.author!.length; // ✅ OK (non null assertion)

타입 좁히기

타입 좁히기(type narrowing)는 복합 타입을 구체적인 단일 타입으로 좁혀나가는 방법을 의미합니다. 타입 좁히기를 수행하는 표현식들을 타입 가드(type guards)라고 합니다.

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

// value => number : toFixed
// value => string : toUpperCase
// value => Date: getTime
// value => Persoon: `name은 age살 입니다.`
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) { // ✅ OK: instanceof type guards
    console.log(value.getTime());
  } else if (value instanceof Person) { // ❌ NO: instanceof는 class에만 사용 가능
    console.log(value.name);
  } else if (value && "age" in value) { // ✅ OK: in type guards 
    console.log(`${value.name}${value.age}살 입니다.`);
  }
}

서로소 유니온 타입

서로소 유니온 타입(Disjoint Union Type)은 각각의 타입이 고유한 tag 속성을 가지는 유니온 타입을 말합니다. 이는 서로 다른 타입을 쉽게 구분할 수 있게 해줍니다.

// 서로소 유니온 타입을 쓰지 않았을 때
type Admin1 = {
  name: string;
  kickCount: number;
};

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

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

type User1 = Admin1 | Member1 | Guest1;

// Admin -> {name}님 현재까지 {kickCount}명 강퇴했습니다.
// Member -> {name}님 현재까지 {point} 모았습니다.
// Guest -> {name}님 현재까지 {visitCount}번 오셨습니다.

// 다믕과 같이 코드를 작성하면 조건식만 보고 어떤 타입으로 좁혀지는지 바로 파악하기가 어렵습니다.
function login(user: User1) {
  if ("kickCount" in user) {
    console.log(`${user.name}님 현재까지 ${user.kickCount}명 강퇴했습니다.`);
  } else if ("point" in user) {
    console.log(`${user.name}님 현재까지 ${point} 모았습니다.`);
  } else {
    console.log(`${user.name}님 현재까지 ${visitCount}번 오셨습니다.`);
  }
}

// 서로소 유니온 타입
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 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;
    }
  }
}

// 비동기 작업의 결과로 서로소 유니온 타입을 쓰지 않았을 때
type AsyncTask = {
  state: "LOADING" | "FAILED" | "SUCCESS";
  error?: {
    message: string;
  };
  response?: {
    data: string;
  };
};

// 로딩 중 -> 콘솔에 로딩중 출력
// 실패 -> 실패: 에러 메시지를 출력
// 성공 -> 성공: 데이터를 출력
function processResult(task: AsyncTask) {
  switch (task.state) {
    case "LOADING": {
      console.log("로딩 중");
      break;
    }
    case "FAILED": {
      console.log(`에러 발생: ${task.error.message}`); // ❌ NO: task.error.message: undefined
      break;
    }
    case "SUCCESS": {
      console.log(`성공: ${task.response.message}`); // ❌ NO: task.response.message: undefined
    }
  }
}
  
const loading: AsyncTask = {
  state: "LOADING",
};

const failed: AsyncTask = {
  state: "FAILED",
  error: {
    message: "오류 발생 원인은 ~~",
  },
};

const success: AsyncTask = {
  state: "SUCCESS",
  response: {
    data: "데이터 ~~",
  },
};

// 비동기 작업의 결과를 처리하는 서로소 유니온 타입
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}`); // ✅ OK
      break;
    }
    case "SUCCESS": {
      console.log(`성공: ${task.error.message}`); // ✅ OK
    }
  }
}


Reference

profile
job's done

0개의 댓글