타입스크립트를 이해한다는 것은, 어떤 기준으로 타입을 정의하고, 타입들 간의 관계를 어떻게 설정하며, 어떤 기준으로 코드의 오류를 판단하는지 등, 타입스크립트의 내부 동작 원리와 그 기준들을 이해하는 것을 의미합니다.
타입은 값들의 집합으로 볼 수 있습니다. 이 집합은 값들이 어떤 특성이나 조건을 만족하는지에 따라 정의됩니다.
타입스크립트에서, 타입들은 포함 관계를 통해 계층을 이룹니다. 이런 관계는 집합의 부분 집합(subset)
과 포함 집합(superset)
관계로 이해할 수 있습니다. 예를 들어, 숫자 리터럴 타입(예: 1, 2, 3 등 특정 숫자)은 숫자 타입(number)의 부분 집합이라고 할 수 있습니다. 즉, 모든 숫자 리터럴 타입은 number
타입에 속하며, number
타입은 그러한 숫자 리터럴 타입들을 모두 포함하는 포합 집합입니다.
이러한 타입의 포함 관계는 업캐스팅(upcasting)
과 다운캐스팅(downcasting)
을 가능하게 합니다. 업캐스팅
은 서브타입(부분 집합)의 값을 슈퍼타입(포함 집합)으로 취급하는 것을 의미하며, 대부분의 경우에 허용됩니다. 반면 다운캐스팅
은 슈퍼타입의 값을 서브타입으로 취급하는 것을 의미하며, 이는 보통 타입을 좁혀나가는 과정에서 명시적으로 타입 체크를 수행해야 가능합니다.
타입스크립트의 기본 타입을 이해하는 데 도움이 되는 타입 계층도입니다. 이 계층도는 타입스크립트의 주요 타입들이 어떻게 다른 타입들과 관련되어 있는지를 시각적으로 보여줍니다.
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
를 사용할 수 있습니다. 이 함수는 항상 예외를 던지거나, 무한 루프에 빠져서 정상적으로 종료되지 않기 때문에 실제로 반환값이 존재하지 않습니다.
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
를 사용합니다.
function voidExam() {
function voidFunc(): void {
console.log("hi");
// 이 함수는 아무런 값도 반환하지 않습니다.
}
let voidVar: void = undefined;
}
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