본문 바로가기

Frontend/Typescript

타입스크립트 이해하기

타입스크립트 이해하기

타입스크립트를 이해한다는 것은 어떤 기준으로 타입을 정의하는지, 어떤 기준으로 타입간의 관계를 정의하는지, 어떤 기준으로 타입의 오류를 검사하는지 등 타입스크립트의 구체적인 원리와 동작 방식을 살펴보는 것이다.

 

타입값들을 포함하고 있는 집합이다.

 

타입 호환성

타입 호환성을 판단할 때는 집합 관계로 생각해보는 것이 좋다.

서브 타입의 값을 슈퍼 타입으로 취급하는 것을 업 캐스팅이라고 하고, 반대의 경우 다운 캐스팅이라고 한다.

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

num1 = num2;
num2 = num1;  // 에러 발생

number 리터럴 타입(서브 타입)을 number 타입(슈퍼 타입)으로 취급하는 것(업 캐스팅)은 가능하나, 반대(다운 캐스팅)는 불가능하다.

 

타입 계층도

타입 계층도를 기준으로 각 타입들의 관계를 파악하면 그 다음부터는 이해가 정말 쉬워진다.

 

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;

    // 다운 캐스팅
    let unknownVar: unknown;
    let num: number = unknownVar; // 'unknown' 형식은 'number' 형식에 할당할 수 없습니다.
    let str: string = unknownVar; // 'unknown' 형식은 'string' 형식에 할당할 수 없습니다.
}

 

never 타입

never 타입은 계층도에서 가장 아래에 있다.

따라서 모든 타입의 서브 타입이다.

공집합으로 생각하면 된다.

/**
 * never 타입
 */

function neverExam() {
    function neverFunc(): never {
        while (true) { }
    }
    
    // 업 캐스팅
    let num: number = neverFunc();
    let str: string = neverFunc();
    let bool: string = neverFunc();

    // 다운 캐스팅
    let never1: never = 10;  // 'number' 형식은 'never' 형식에 할당할 수 없습니다.
    let never2: never = 'string';  // 'string' 형식은 'never' 형식에 할당할 수 없습니다.
}

 

void 타입

/**
 * void 타입
 */

function voidExam() {
    function voidFunc(): void {
        console.log('hi');
        return undefined;
    }

    // 업 캐스팅
    let voidVar: void = undefined;
}

 

any 타입

any 타입은 모든 타입의 슈퍼 타입으로 위치하기도 하고, never를 제외하고 모든 타입의 서브 타입으로 위치하기도 한다.

any 타입은 never 타입을 제외하고는 업 캐스팅, 다운 캐스팅이 모두 가능하다.

never 타입은 공집합이기 때문에 any 타입조차 다운 캐스팅을 할 수 없다.

/**
 * any 타입
 */

function anyExam() {
    let unknownVar: unknown;
    let anyVar: any;
    let undefinedVar: undefined;
    let neverVar: never;

    // 다운 캐스팅
    anyVar = unknownVar;  // 문제 없음

    undefinedVar = anyVar;  // 문제 없음

    neverVar = anyVar;  // 'any' 형식은 'never' 형식에 할당할 수 없습니다.
}

 

객체 타입의 호환성

타입스크립트구조적 타입 시스템을 따르기 때문에, 객체 타입은 프로퍼티를 기준으로 집합 관계를 갖는다.

아래 예에서는 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: 'black',
    breed: '치와와'
}

// 업 캐스팅
animal = dog;

// 다운 캐스팅
dog = animal;  // 'breed' 속성이 'Animal' 형식에 없지만 'Dog' 형식에서 필수입니다.
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;

// 다운 캐스팅
programmingBook = book;  // 'skill' 속성이 'Book' 형식에 없지만 'ProgrammingBook' 형식에서 필수입니다.

/**
 * 초과 프로퍼티 검사
 */
let book2: Book = {
    name: '한 입 크기로 잘라먹는 리액트',
    price: 33000,
    skill: 'reactjs'  // '{ name: string; price: number; skill: string; }' 형식은 'Book' 형식에 할당할 수 없습니다. 
                      // 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Book' 형식에 'skill'이(가) 없습니다.
}

let book3: Book = programmingBook;

function func(book: Book) { }

func({
    name: '한 입 크기로 잘라먹는 리액트',
    price: 33000,
    skill: 'reactjs'  // '{ name: string; price: number; skill: string; }' 형식의 인수는 'Book' 형식의 매개 변수에 할당될 수 없습니다.
                      // 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Book' 형식에 'skill'이(가) 없습니다.
})

func(programmingBook);

위 코드에서 객체 초기화객체 리터럴을 사용하는 경우, 딱 객체 타입에 정의된 것들만 넣을 수 있도록 초과 프로퍼티 검사를 한다.

그래서 Book 타입의 경우, name, price만 정의되었기 때문에 추가적으로 skill을 정의해주면 에러가 난다.

객체 리터럴을 사용하지 않으면 초과 프로퍼티 검사를 수행하지 않는다.

또한, 함수의 인수객체 리터럴을 사용하는 경우도 마찬가지로 초과 프로퍼티 검사를 한다.

 

대수 타입

/**
 * 대수 타입
 * -> 여러 개의 타입을 합성해서 새롭게 만들어낸 타입
 * -> 합집합 타입과 교집합 타입이 존재한다.
 */

/**
 * 1. 합집합 - Union 타입
 */

let a: string | number | boolean;
a = 1;
a = 'hello';
a = true;

let arr: (number | string | boolean)[] = [1, 'hello', true];

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: '',
    language: '',
    color: ''
}

let union4: Union1 = {
    name: ''  // '{ name: string; }' 형식은 'Union1' 형식에 할당할 수 없습니다.
              // 'language' 속성이 '{ name: string; }' 형식에 없지만 'Person' 형식에서 필수입니다.
}

/**
 * 2. 교집합 - Intersection 타입
 */

let variable: number & string;  // never

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;
let b = 'hello';
let c = {
    id: 1,
    name: '이정환',
    profile: {
        nickname: 'winterlood'
    },
    urls: ['<https://winterlood.com>'],
}

let { id, profile } = c;
let [one, two, three] = [1, 'hello', true];

 

함수의 경우, 매개변수에 디폴트 값이 정의되어있다면, 디폴트 값을 기준으로 타입을 추론한다.

function func(message='hello') {
    return 'hello';
}

 

변수를 선언하고 초기값을 지정하지 않으면 암묵적인 any 타입으로 추론된다.

이후 변수에 들어가는 값에 따라서 any 타입이 진화하게 된다.

단, 명시적 any 타입과 암묵적 any 타입은 다르다.

명시적 any 타입의 경우 진화하지 않고 계속 any 타입이다.

/**
 * any 타입의 진화
 */
let d;  // any 타입

d = 10; 
d.toFixed(); // number 타입  

d = 'hello';  
d.toUpperCase(); // string 타입
let num = 10;  // number 타입

const num = 10;  // 10 타입

const str = 'hello';  // 'hello' 타입

또한 위 코드에서 let으로 선언했을 때는 조금 더 넓은 타입인 number 타입으로 인식(타입 넓히기)했으나, const로 선언한 경우에는 10이라는 넘버 리터럴 타입으로 인식했다.

 

타입 단언

/**
 * 타입 단언
 */

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

let person1: Person = {};  // '{}' 형식에 'Person' 형식의 name, age 속성이 없습니다.
person1.name = '이정환';
person1.age = 27;

let person2 = {};
person2.name = '이정환';  // '{}' 형식에 'name' 속성이 없습니다.
person2.age = 27;  // '{}' 형식에 'age' 속성이 없습니다.

let person3 = {} as Person;
person3.name = '이정환';
person3.age = 27;

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

// 초과 프로퍼티 검사
let dog1: Dog = {
    name: '쭈기',
    color: 'brown',
    breed: '치와와'  // '{ name: string; color: string; breed: string; }' 형식은 'Dog' 형식에 할당할 수 없습니다.
                     // 개체 리터럴은 알려진 속성만 지정할 수 있으며 'Dog' 형식에 'breed'이(가) 없습니다.
}

let dog2 = {
    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;  // 'number' 형식을 'string' 형식으로 변환한 작업은 실수일 수 있습니다.
                          // 두 형식이 서로 충분히 겹치지 않기 때문입니다.
                          // 의도적으로 변환한 경우에는 먼저 'unknown'으로 식을 변환합니다.

// 다중 단언
let num4 = 10 as unknown as string;

위 코드에서 num1의 경우, 10은 number 타입이고, never는 모든 타입의 서브 타입이기 때문에 에러가 발생하지 않는다.

num2의 경우, 10은 number 타입이고, unknown은 전체 집합으로 취급하기 때문에 에러가 발생하지 않는다.

 

/**
 * const 단언
 */

let num5 = 10 as const;

let cat = {
    name: '쭈기',
    color: 'black'
} as const;

const 단언const로 선언한 것과 같은 효과를 내는 단언이다.

객체에 as const로 const 단언을 해주게 되면 모든 프로퍼티가 readonly 프로퍼티가 된다.

 

/**
 * Non Null 단언
 */

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

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

const len1: number = post.author?.length;  // 'number | undefined' 형식은 'number' 형식에 할당할 수 없습니다.
                                        // 'undefined' 형식은 'number' 형식에 할당할 수 없습니다.

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

!(Non null 단언)은 해당 값이 null이나 undefined가 아닐 것이라고 표시하는 것이다.

 

타입 단언은 타입이 확실한 경우에만 사용하는 것이 좋다.

 

타입 좁히기

typeof와 같이 타입을 좁힐 수 있게 하는 것을 타입 가드라고 한다.

/**
 * 타입 좁히기
 * 조건문 등을 이용해 넓은 타입에서 좁은 타입으로
 * 타입을 상황에 따라 좁히는 방법을 말한다.
 */

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

function func(value: number | string | Date | null | Person) {
    value.toUpperCase();  // 'string | number' 형식에 'toUpperCase' 속성이 없습니다.
                          // 'number' 형식에 'toUpperCase' 속성이 없습니다.
    value.toFixed();  // 'string | number' 형식에 'toFixed' 속성이 없습니다.
                      // 'string' 형식에 'toFixed' 속성이 없습니다.

    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}세입니다.`);
    }
}

typeof null의 결과도 ‘object’이기 때문에 typeof value를 ‘object’와 비교하는 것은 좋은 방법이 아니다.

그래서 instanceof를 사용해서 타입 가드를 하면 된다.

하지만 instanceof의 우측에는 타입이 들어와서는 안 된다.

instanceof는 클래스에 사용되는 것이다.

 

서로소 유니온 타입

/**
 * 서로소 유니온 타입
 * 교집합이 없는 타입들로만 만든 유니온 타입을 말한다.
 */

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) {
    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}번 방문하셨습니다.`);
    }
}

// 직관적인 코드
function login2(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 login3(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;
        }  
    }
}

Admin, Member, Guest 타입에 tag 프로퍼티를 정의해주지 않은 경우에는 교집합이 존재하는 경우가 있었다.

예를 들어서 name, kickCount, point 프로퍼티를 가진 경우, Admin과 Member 타입의 교집합인 것이다.

하지만, tag 프로퍼티를 추가해주면 각 집합은 서로소 관계가 된다.

이를 태그드 유니온 타입이라고도 부른다.

 

// 비동기 작업의 결과를 처리하는 객체
// type AsyncTask = {
//     state: 'LOADING' | 'FAILED' | 'SUCCESS',
//     error?: {
//         message: string;
//     },
//     response?: {
//         data: string;
//     }
// }

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

const loading: LoadingTask = {
    state: 'LOADING',
}

const failed: FailedTask = {
    state: 'FAILED',
    error: {
        message: '오류 발생 원인은 ...',
    }
}

const success: SuccessTask = {
    state: 'SUCCESS',
    response: {
        data: '데이터 ...',
    }
}

 

 

'Frontend > Typescript' 카테고리의 다른 글

제네릭  (0) 2023.06.20
인터페이스와 클래스  (0) 2023.06.20
함수와 타입  (0) 2023.06.20
타입스크립트 기본  (0) 2023.06.20
타입스크립트 개론  (0) 2023.06.19