이펙티브 타입스크립트 스터디(4)

오형근·2022년 9월 10일
0

Typescript

목록 보기
11/15
post-thumbnail

Effective Typescript에 대한 스터디 진행 내용을 요약한 것입니다.

제네릭에 대한 이해

제네릭은 함수 내에서 지속적으로 사용될 타입을 인자로 받아 상황에 따라 함수에 사용될 타입을 유연하게 설정할 수 있도록 만든 기능이다.

대부분의 내용을 저번 글에 작성하였기에, 이번에는 제네릭으로 주로 사용되는 문자들의 관례만 살펴보고 넘어가고자 한다.

E - Element
K - Key
N - Number
T - Type
V - Value

관용적인 표현들이니까 알아두면 좋을 듯 하다!!

타입 공간과 값 공간의 심벌 구분하기

Typescript의 Symbol은 타입 공간이나 값 공간에 존재한다. 심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있기 때문에 혼란스러울 수 있다.

아래 코드를 살펴보자.

interface Cylinder {
    radius: number;
    height: number;
};

const Cylinder = (radius: number, height: number): Cylinder => ({radius, height});

위 코드에서 인터페이스로 선언된 Cylinder와 값으로 선언된 Cylinder는 완전히 다른 취급을 받으므로 충돌을 야기하지 않는다. 다만 이러한 선언은 혼란을 불러올 수 있으므로 주의해야 한다.

아래 코드에서 그 잘못된 사례가 잘 나타나고 있다.

interface Cylinder {
    radius: number;
    height: number;
};

const Cylinder = (radius: number, height: number): Cylinder => ({radius, height});
function calculateVolume(shape: unknown) {
    if(shape instanceof Cylinder) {
        shape.radius
        // Property 'radius' does not exist on type '{}'.
    }
};

calculateVolume함수에서 쓰인 Cylinder는 앞에 instanceof연산자만 보아도 값이 와야한다고 판단할 수 있지만, 개발자가 실수를 절대 하지 않으리라는 보장은 없다. 이러한 경우 shape을 사용하는 과정에서 발생하는 오류를 정확히 체크하지 못하는 일이 발생할 수도 있다. 물론 오류를 보고 금방 수정하겠지만, 사전에 방지하자는 것이다!!!

또한 파일의 크기가 커지고 코드 구조가 복잡해지면 이러한 오류를 금방 잡아내기 어려울 수도 있다. 같은 심볼로 사용되는 값과 타입이 있다면 이 중 어떤 것이 사용되었는지를 파악하기 위해 이전 문맥들을 모두 살펴보는 불상사가 일어날 수 있으므로, 같은 이름의 심볼을 사용하는 것은 지양하자.

책에서는 타입과 값을 구분하는 간단한 방법으로 다음을 소개하고 있다.

":" 뒤에 나오는 것들은 모두 타입입니다.
"=" 뒤에 나오는 것들은 모두 값입니다.

위의 방법을 이용하면 타입과 값을 구분하는 것이 한층 더 쉽지 않을까?

정말 안타깝게도, TS 내에서 class와 enum은 타입과 값 모두로 사용 가능하다..
이는 특이한 경우를 다수 불러오는데, 다음 코드를 살펴보자.

class Cylinder {
    radius = 1;
    height = 1;
}

function calculateVolume(shape: unknown) {
    if(shape instanceof Cylinder) {
        shape; // It's type is Cylinder!
        shape.radius; // It's type is number!
    }
}

위 코드에서는 크게 헷갈릴 일이 없겠지만, 아래 코드처럼 typeof를 사용하는 경우는 다르다.

interface Person {
    first: string;
    last: string;
}

const p: Person = { first: "Jane", last: "Jacobs" };

const email = (p: Person, subject: string, body: string): Response => {
    return new Response()
}

class Cylinder {
    radius = 1;
    height = 1;
}

function calculateVolume(shape: unknown) {
    if(shape instanceof Cylinder) {
        shape; // It's type is Cylinder!
        shape.radius; // It's type is number!
    }
}

type t1 = typeof p
type t2 = typeof email

const v1 = typeof p
const v2 = typeof email

위의 코드에서 타입을 지정하는 경우 p가 선언될 때 타입이 Person으로 명시되어있었으니 타입이 Person으로 나오게 된다. 그러나 값을 지정하는경우 p의 값이 객체로 선언되었으므로 타입이 객체로 나오게 되는 것이다.

충분히 헷갈리기 쉬운 부분이라고 생각한다. 코드를 짤 때 충돌을 일으키지 않도록 주의를 기울이자.

typeof (클래스)는 생성자 함수를 반환한다

위의 코드에서 typeof Cylinder는 어떤 것을 반환하는 것일까?
이 친구는 typeof (클래스)를 말하는 것인데, 생성자 함수를 반환한다!

즉, 해당 클래스의 인스턴스를 만들어주는 생성자 함수가 반환되는 것이다.
그래서 타입으로 쓴다면 단순하게 typeof (클래스)를 반환하게 될 것이고,
값으로 쓰인다면 function을 반환하는 것이다.

type t = typeof Cylinder // Type is typeof Cylinder
const type = typeof Cylinder // Value is function
type v = InstanceType<typeof Cylinder> // Type is Cylinder

마찬가지로 헷갈리지 않도록 주의하자.

타입 내부 속성에 접근할 때는 "[]"으로 접근하자

타입 내의 속성을 얻고자 할 때에는 평소 객체 내부 속성에 접근하는 것처럼 "."표기법을 사용하면 안된다. 다음 코드를 살펴보자.

interface Person {
    first: string;
    last: string;
}

const first: Person.first = p.first
//Cannot access 'Person.first' because 'Person' is a type, but not a namespace. Did you mean to retrieve the type of the property 'first' in 'Person' with 'Person["first"]'?

이처럼 에러가 발생하고, TS 자체에서 Person이 값이 아닌 타입이므로 "." 표기법이 아닌 "[]"으로 접근해야한다고 경고하고 있다.

반면에 p는 값이므로 p.first에는 문제가 발생하지 않는다.

구조분해할당 시 타입 지정하기

요 예제는 나도 프로젝트를 진행하면서 자주 겪은 내용이고, 다들 많이 알고 있다고 생각한다.

평소 함수의 인자를 정의해주면서 구조분해할당을 유용하게 쓰는데, 이에 대한 타입을 지정하는 과정에서 발생하는 에러가 있다.

다음 코드를 살펴보자.

interface Person {
    first: string;
    last: string;
}
const email = ({person: Person, subject: string, body: string}) => {
}
// 'Person' is declared but its value is never read.
// Binding element 'Person' implicitly has an 'any' type.

이처럼 구조분해할당된 인자에 바로 ':'을 이용하면 타입을 지정해준다고 생각하기 쉽지만, 이와 같은 경우 값을 할당해주게 된다. 그래서 타입이 지정되지 않아 any로 추론된다고 에러가 발생하므로, 아래와 같이 고쳐주도록 하자.

interface Person {
    first: string;
    last: string;
}
const email = ({person, subject, body}: {person: Person, subject: string, body: string}) => {
}

타입 단언보다는 타입 선언을 사용하기

TS에서 타입을 명시해주는 방법에는 타입 단언과 선언이 있다. 다음을 보자.

interface Person {
    name: string;
}

const alice: Person = { name: "alice" }; //타입 선언
const bob = { name: "bob" } as Person; // 타입 단언

위 코드에서 alice처럼 타입을 처음에 명시해주는 것을 타입 선언, bob처럼 뒤에서 ~타입처럼 여긴다의 의미를 가지는 명시 방법이 타입 단언이다. 둘은 같은 의미를 가지지만, 타입을 체크하는 과정에서 얼마나 엄격한지에 따라 달라진다.

타입 단언은 값이 할당된 뒤에 타입을 지정해 그 타입으로 '여기는'느낌이기 때문에 다음과 같은 경우에도 에러를 발생시키지 않는다.

const bob = {} as Person;

Person내부의 속성인 name이 "bob"에 존재하지 않더라도 아무 문제가 없는 것이다.

그러나 타입 선언의 경우는 다르다. 다음 코드를 보면서 확인하자.

const alice: Person = {};
// Property 'name' is missing in type '{}' but required in type 'Person'.

또한 타입 단언과 선언은 새로운 속성이 추가될 때에도 차이를 보인다. 다음 코드를 살펴보자.

interface Person {
    name: string;
}

const alice: Person = {
    name: "alice",
    occupation: "Typescript developer",
};
// Type '{ name: string; occupation: string; }' is not assignable to type 'Person'.
// Object literal may only specify known properties, and 'occupation' does not exist in type 'Person'.

const bob = { 
    name: "bob" ,
    occupation: "Typescript developer",
} as Person;

위의 코드처럼 타입 선언의 경우 초기 선언된 타입을 완전히 따르지 않으면 에러가 발생한다. 그러나 타입 단언의 경우 그것과 무관하게 객체의 속성을 추가하고 삭제할 수 있다.

이제 타입 선언과 타입 단언의 차이를 확연히 알 수 있다!!
우리는 더 엄격하고 체계적인 코드 설계를 위해 타입 선언을 주로 사용하되, 상황에 맞게 타입 단언을 쓸 수 있도록 연습하는 것이 좋을 것 같다.

타입 단언으로 코드를 작성할 때 단점이 드러나는 예제를 살펴보자.

interface Person {
    name: string;
}

const people = ["alice", "bob", "jan"].map(name => ({} as Person))

위의 코드는 타입 단언을 사용하였으므로 에러를 발생시키지 않는다. 이러한 경우 나중에 people의 타입을 Person[]으로 간주하고 사용하게 될텐데, 반환값을 보면 누가 봐도 Person이 아니다...추가적인 에러를 발생시키기 쉬운 부분이다. 이러한 경우 때문에라도 타입 단언보다 타입 선언을 더 애용해보자.

그렇다면 타입 선언을 사용하면 어떻게 코드를 작성해야 할까?

interface Person {
    name: string;
}

const people = ["alice", "bob", "jan"].map(name => {
    const person: Person = { name };
    return person;
}) // Type is Person

이처럼 처음부터 person이라는 반환값의 타입을 타입 선언을 통해 명시해주자!

아니면 아래 코드처럼 처음 변수를 선언할 때부터 타입을 선언해주는 방법도 좋다.

interface Person {
    name: string;
}

const people: Person[] = ["alice", "bob", "jan"].map(name => {
    const person = { name };
    return person;
})

이렇게 작성하면 person을 반환하는 과정에서 person의 타입이 충분히 추론되기 때문이다.
이 외에도 제네릭을 쓰는 등 방법은 많지만, 본인과 팀이 가장 이해하기 쉬운 방법을 적용하자.

타입 단언이 필요한 경우

그럼에도 타입 단언이 사용되는 곳이 있다면, Non-null assertion을 위해서일 것이다.

변경이 잦은 비동기 코드나 React의 경우 초기화된 특정 변수에 값이 담길 수도 있고 그렇지 않을 수도 있다. 이러한 경우 TS에서는 타입을 ~~~ | null 혹은 ~~~ | undefined로 추론하는데, 이러한 경우 이후 이어지는 메서드 사용이나 함수 사용을 위해서는 nullundefined 타입을 정제해주어야 한다. 이 때 사용될 수 있는게 타입 단언인데, 다음 코드를 살펴보자.

const divEl = document.querySelector('#myButton');
if(divEl) {
    divEl.addEventListener('click', e => {
        e.currentTarget; // Type is EventTarget
        const button = e.currentTarget as HTMLButtonElement;
        button; // Type is HTMLButtonElement
    })
}

위 코드처럼 처음에는 EventTarget타입으로 지정된 변수의 타입을 타입 단언을 이용하여 가공해주었다. 이는 EventTargetHTMLButtonElement와 서로의 서브 타입이기 때문에 가능하다. 즉, 충분히 유사한 타입인 경우 타입 단언이 수용한다!! 이와 같이 타입을 단언하는 경우 이후 코드에서 변수의 용도에 맞게 타입을 바꾸어줄 수 있다.

또한 null을 배제하는 Non-null assertion도 이루어지므로 타입 정제에도 효과적이다.

만일 Non-null assertion의 기능만이 필요하다면 연산자!를 쓰면 될 것 같다..!

다음 예제를 추가적으로 살펴보자.

interface Person {
    name: string;
}

const body = document.body;
const el = body as Person;
// Conversion of type 'HTMLElement' to type 'Person' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// Property 'name' is missing in type 'HTMLElement' but required in type 'Person'.

위 코드에서 body의 타입은 HTMLElement인데 이는 Person과 충분히 겹치지 않아 타입 단언을 통해 타입을 가공할 수 없다고 나와있다. 그래서 만일 의도적으로 타입을 바꾸어야 하는 경우, unknown을 먼저 이용하여 타입을 우회하여 변경할 수 있다.

interface Person {
    name: string;
}

const body = document.body;
const el = body as unknown as Person;

물론 any를 사용해도 되겠지만, unknown을 사용하는 것이 좋다.

이쯤에서 둘의 차이를 짚고 넘어가보면

unknown은 TS 범위 내 있으므로 추후 타입을 정제하여 명시해준 뒤에 사용해야 하지만, any는 그냥 구문을 TS 문법 밖으로 내보내 JS와 같이 사용할 수 있도록 만들어버린다. 타입을 지정해줄 필요가 없어지는 것...분명 위험하다!

따라서 최대한 any를 줄이고 이왕이면 unknown을 사용할 수 있도록 해보자.

0개의 댓글