이펙티브 타입스크립트 3장

정태호·2023년 8월 9일
0

타입스크립트

목록 보기
8/13
post-thumbnail

타입추론

아이템 19. 추론 가능한 타입을 사용해 장황한 코드 방지하기

  • 타입스크립트의 많은 타입 구문은 사실 불필요하다.
  • 타입 추론이 된다면 명시적 타입 구문은 필요하지 않다.
let x: number = 12; // 타입추론 가능
let x = 12; // OK

때로는 우리가 예상한 것보다 더 정확할 때도 있다.

const axis1: string = 'x'; // 타입은 string
const axis2 = 'y'; // 타입은 'y'

비구조화 할당문은 모든 지역 변수의 타입이 추론되도록 한다. 여기서 추가적으로 명시적 타입 구문을 넣는다면 불필요한 타입 선언으로 인해 코드가 번잡해진다.

interface Product {
    id: string;
    name: string;
    price: number;
}

function logProduct(product: Product) {
    const {id, name, price} = product; // Good
    const {id, name, price}: {id: string; name: string; price: number} = product; // 이렇게 할 필요가 없다.
}

정보가 부족해 타입스크립트가 스스로 타입을 판단하기 어려운 상황도 있기 때문에 그럴 때는 명시적 타입 구문이 필요하다. 타입스크립는 최종 사용처까지 고려하여 타입 추론을 하지 않기 때문에 변수의 타입은 일반적으로 처음 등장할 때 결정된다.

타입이 추론될 수 있음에도 여전히 타입을 명시하고 싶은 상황도 있다. 예를 들면 객체 리터럴을 정의할 때이다. 타입을 명시하면 잉여 속성 체크가 동작하며, 변수가 사용되는 순간이 아닌 할당하는 시점에 오류가 표시 되도록 해준다.

또한 함수의 반환에도 타입을 명시하여 오류를 방지할 수 있다. 타입 추론이 가능할 지라도 구현상의 오류가 함수를 호출한 곳까지 영향을 미치지 않도록 하기 위해 타입 구문을 명시하는 것이 좋다.

  • 반환 타입을 명시하면 함수에 대해 더욱 명확하게 알 수 있기 때문이다. 추후에 코드가 조금 변경되어도 그 함수의 시그니처는 쉽게 바뀌지 않는다. 미리 타입을 명시하는 방법은, 함수를 구현하기 전에 테스트를 먼저 작성하는 TDD와 비슷하다. 전체 타입 시그니처를 먼저 작성하면 구현에 맞추어 주먹구구식으로 시그니처가 작성되는 것을 방지하고 제대로 원하는 모양을 얻게 된다.
  • 명명된 타입을 사용하기 위해서이다. 반환 타입을 명시하면 더욱 직관적인 표현이 된다. 추론된 반환 타입이 복잡해질수록 명명된 타입을 제공하는 이점은 커진다.

요약

  • 타입스크립트가 타입을 추론할 수 있다면 타입 구문을 작성하지 않는 게 좋다.
  • 이상적으로 함수/메서드 시그니처에 타입 구문을 포함하고 함수 내에서 생성된 지역변수에는 타입 구문을 넣지 않는다.
  • 추론되는 경우라도 객체 리터럴과 함수 반환에는 타입 명시를 고려해야 한다. 내부 구현의 오류가 사용자 코드 위치에 나타나는 것을 방지해 준다.

아이템 20. 다른 타입에는 다른 변수 사용하기

let id = "12-34-56";
sum(id); 
id = 123456;
fetchProduct(id); // Error
// string 형식의 인수는 number형식의 매개변수에 할당될 수 없습니다.

여기서 변수의 값은 바뀔 수 있지만 그 타입은 보통 바뀌지 않는다! 는 중요한 관점을 알 수 있다.

변수를 무분별하게 재사용하면 타입 체커와 사람 모두에게 혼란을 줄 뿐이다.

다른 타입에는 별도의 변수를 사용하는 게 바람직한 이유는 다음과 같다.

  • 서로 관련이 없는 두 개의 값을 분리한다.
  • 변수명을 더 구체적으로 지을 수 있다.
  • 타입 추론을 향상시키며, 타입 구문이 불필요해진다.
  • 타입이 더 간결해진다.(string|number 대신 string과 number를 사용)
  • let 대신 const로 변수를 선언할 수 있다.

요약

  • 변수의 값은 바뀔 수 있지만 타입은 일반적으로 바뀌지 않는다.
  • 타입이 다른 값을 다룰 때는 변수를 재사용하지 않도록 하자!

아이템 21. 타입 넓히기

  • 런타임에 모든 변수는 유일한 값을 가지지만 타입스크립트가 작성된 코드를 체크하는 정적 분석 시점에 변수는 가능한 값들의 집합인 타입을 갖는다.
  • 즉, 지정된 단일 값을 가지고 할당 가능한 값들의 집합을 유추해야 한다. 이러한 과정을 넓히기(widening)라고 부른다.
interface Vector3 { x: number, y: number; z: number }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
    return vector[axis];
}

let x = 'x'; // string 타입으로 추론
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x); // ~ 'string' 형식의 인수는 '"x" | "y" | "z"' 형식의 매개변수에 할당될 수 없다.

넓히기의 과정을 제어하는 방법

let 대신 const 사용하기

const x = 'x'; // 'x' 타입으로 추론
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x); // ok

x는 재할당될 수 없으므러 타입스크립트는 더 좁은 타입인 'x' 타입으로 추론할 수 있다. 하지만 객체나 배열같은 참조형 데이터에는 문제가 발생한다.

객체의 경우 타입스크립트의 넓히기 알고리즘은 각 요소를 let으로 할당된 것처럼 다룬다.

타입 추론의 강도를 직접 제어하려면 타입스크립트의 기본 동작을 재정의해야 한다.

타입스크립의 기본 동작을 재정의하는 방법

명시적 타입 구문 제공

const v: {x: 1|3|5} = {
  x: 1
} // 타입은 {x: 1|3|5}

타입 체커에 추가적인 문맥 제공

const 단언문 사용하기

  • const 단언문은 온전히 타입 공간의 기법이다.
  • as const를 작성하면 최대한 좁은 타입으로 추론한다.
const v1 = {
  x: 1,
  y: 2,
}; // {x: number, y: number}

const v2 = {
  x: 1 as const,
  y: 2,
}; // {x: 1, y: number}

const v3 = {
  x: 1,
  y: 2,
} as const; //{readonly x: 1, readonly y: 2}

요약

  • 타입스크립트가 넓히기를 통해 상수의 타입을 추론하는 법을 이해하자.
  • 동작에 영향을 줄 수 있는 방법인 const, 타입 구문, 문맥, as const에 익숙해지자.

아이템 22. 타입 좁히기

타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말한다. null 체크를 하거나 instanceof, 속성 체크 등으로 타입을 좁힐 수 있다. 또는 Array.isArray() 같은 일부 내장 함수로도 타입을 좁힐 수 있다.

타입스크립트는 일반적으로 조건문에서 타입을 좁히는 데 매우 능숙하다. 만약 타입스크립트가 타입을 식별하지 못한다면, 식별을 돕기 위해 커스텀 함수를 도입할 수 있다.

function isInputElement(el: HTMLElement): el is HTMLInputElement {
    return 'value' in el;
}

function getElementContent(el: HTMLElement) {
    if(isInputElement(el)) {
        el; // 타입이 HTMLInputElement
        return el.value;
    }
    el;
    return el.textContent;
}

이러한 기법을 사용자 정의 타입 가드라고 한다. 반환 타입의 el is HTMLInputElement는 함수의 반환이 true인 경우, 타입 체커에게 타입을 좁힐 수 있다고 알려 준다.

어떤 함수들은 타입 가드를 사용하여 배열과 객체의 타입 좁히기를 할 수 있다.

const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael'];

const members = ['Janet', 'Micheal'].map(
    who => jackson5.find(n => n === who)
).filter(who => who !== undefined); // 타입이 (string | undefined)[]

function isDefined<T>(x: T | undefined): x is T {
    return x !== undefined;
}

const members2 = ['Janet', 'Micheal'].map(
    who => jackson5.find(n => n === who)
).filter(isDefined); // 타입이 string[]

요약

  • 여러 종류의 제어 흐름을 살펴보며 타입스크립트가 타입을 좁히는 과정을 이해하자.
  • 태그된/구별된 유니온과 사용자 정의 타입 가드등을 사용하여 타입 좁히기 과정을 원활하게 만들자!

아이템 23. 한꺼번에 객체 생성하기

  • 객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 추론에 유리하다.
const pt = { x: 3, y: 4 };
const id = { name: "Pythagoras" };
const namePoint = { ...pt, ...id };
namePoint.name;

다음과 같이 객체 전개 연산자 '...'을 사용해 큰 객체를 한꺼번에 만들어 낼 수 있다.

타입에 안전한 방식으로 조건부 속성을 추가하려면, 속성을 추가하지 않는 null 또는 {} 으로 객체 전개를 사용하면 된다.

declare let hasMiddle: boolean;
const firstLast = {first: 'Harry', last: 'Truman'};
const president = {...firstLast, ...(hasMiddle ? { middle: '5' } : {})};

편집기에서 president를 보면 타입이 선택적 속성을 가진 것으로 추론된다는 것을 알 수 있다.

const president: {
    middle?: string;
    first: string;
    last: string;
}

전개 연산자로 한꺼번에 여러 속성을 추가할 수도 있다.

declare let hasDates: boolean;
const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
const pharaoh = {
    ...nameTitle,
    ...(hasDates ? {start: -2589, end: -2566} : {})
};

// 타입 (책과 다르다? page130)
const pharaoh: {
    start?: number | undefined;
    end?: number | undefined;
    name: string;
    title: string;
}

만약 이를 선택적 필드 방식으로 표현하려면 다음처럼 헬퍼 함수를 사용하면 된다.

function addOptional<T extends object, U extends object>(
    a: T, b: U | null
): T & Partial<U> {
    return { ...a, ...b };
}

const pharaoh = addOptional(
    nameTitle,
    hasDates ? { start: -2589, end: -2566 } : null
);
pharaoh.start // 정상, 타입이 number | undefined

가끔 객체나 배열을 변환해서 새로운 객체나 배열을 생성하고 싶을 수 있다. 이런 경우 루프 대신 내장된 함수형 기법 또는 로대시(Lodash)같은 유틸리티 라이브러리를 사용하는 것이 '한꺼번에 객체 생성하기' 관점에서 보면 옳다.

요약

  • 속성을 한꺼번에 객체로 만들자. 안전한 타입으로 속성을 추가하려면 객체 전개를 사용하자.
  • 객체에 조건부 속성을 추가하는 방법에 익숙해지자!

아이템 24. 일관성 있는 별칭 사용하기

const borough = {name: 'Brooklyn', location: [40.688, -73.979]};
const loc = borough.location; // borough.location에 loc 별칭 사용

loc[0] = 0;
borough.location; // [0, -73.979] 원래 속성값도 0으로 변경되서 나타남

별칭을 남발해서 사용하면 제어 흐름을 분석하기 어렵다. 별칭을 신중하게 사용해야 코드를 잘 이해할 수 있고, 오류도 쉽게 찾을 수 있다.

요약

  • 별칭은 타입스크립트가 타입을 좁히는 것을 방해한다. 따라서 변수에 별칭을 사용할 때는 일관되게 사용해야 한다.
  • 비구조화 문법을 사용해서 일관된 이름을 사용하는 것이 좋다.
  • 함수 호출이 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의하자! 속성보다 지역변수를 사용하면 타입 정제를 믿을 수 있다.

아이템 25. 비동기 코드에는 콜백 대신 async 함수 사용하기

ES5 또는 더 이전 버전을 대상으로 할 때, 타입스크립트 컴파일러는 async와 await가 동작하도록 정교한 변환을 수행한다. 다시 말해, 타입스크립트는 런타임에 관계없이 async/await를 사용할 수 있다.

콜백보다 프로미스나 async/await를 사용해야 하는 이유는 다음과 같다.

  • 콜백보다는 프로미스가 코드를 작성하기 쉽다.
  • 콜백보다는 프로미스가 타입을 추론하기 쉽다.
    예를 들어 병렬로 페이지를 로드하고 싶다면 Promise.all을 사용하여 프로미스를 조합하면 된다.
async function fetchPage() {
    const [response1, response2, response3] = await Promise.all([
        fetch(url1), fetch(url2), fetch(url3)
    ]);
    // ...
}

선택의 여지가 있다면 프로미스를 생성하기보다는 async/await를 사용해야 한다.

  • 일반적으로 더 간결하고 직관적인 코드가 된다.
  • async 함수는 항상 프로미스를 반환하도록 강제한다.

요약

  • asyncawait를 사용하는 것이 좋다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 제거할 수 있다.
  • 어떤 함수가 프로미스를 반환한다면 async로 선언하자!

아이템 26. 타입 추론에 문맥이 어떻게 사용되는지 이해하기

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지는 않는다. 값이 존재하는 곳의 문맥까지 살핀다. 문맥을 고려하다 보면 가끔 이상한 결과가 나온다.

type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }

// 인라인 형태
setLanguage('JavaScript'); // 정상

// 참조 형태
let language = 'JavaScript';
setLanguage(language); // 'string' 타입의 인수는 'language' 형식의 매개변수에 할당할 수 없습니다.

값을 변수로 분리하면 타입스크립트는 할당 시점에 타입을 추론한다. 위의 경우는 string으로 추론했고, Language 타입으로 할당이 불가능하므로 오류가 발생하였다.

이러한 문제를 해결하는 데에는 두 가지 방법이 있다.

타입 선언에서 language의 가능한 값을 제한

let language: Language = 'JavaScript';
setLanguage(language); // 정상

const를 사용하기

const language = 'JavaScript';
setLanguage(language); // 정상

튜플 사용 시 주의점

function panTo(where: [number, number]) { /* ... */ }

panTo([10, 20]); // 정상

const loc = [10, 20]; // number[] 로 추론
panTo(loc); // 'number[]' 형식의 인수는 '[number, number]' 형식의 매개변수에 할당할 수 없다.

// 해결방안 타입 선언 제공
const loc: [number, number] = [10,20];
panTo(loc); // ok

// 함께 readonly로 만들기
function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10,20] as const;
panTo(loc); // ok

as const는 문맥 손실과 관련된 문제를 해결할 수 있지만, 타입 정의에 실수가 있다면 오류는 타입 정의가 아니라 호출되는 곳에서 발생한다는 단점이 있다. 그렇기에 복잡한 객체라면 오류가 발생한 근본적인 원인을 파악하기 어려울 수도 있다.

객체 사용 시 주의점

  • 객체 사용 시에도 위와 같은 문제가 발생하므로 타입 선언을 추가하거나 상수 단언(as const)을 사용해 해결한다.

콜백 사용 시 주의점

콜백을 다른 함수로 전달할 때, 타입스크립트는 콜백의 매개변수 타입을 추론하기 위해 문맥을 사용한다. 콜백은 상수로 뽑아내면 문맥이 소실되고 noImplicitAny 오류가 발생하게 되는데, 이런 경우는 매개변수에 타입 구문을 추가해서 해결할 수 있다.

가능하면 전체 함수 표현식에 타입 선언을 적용하자!

요약

  • 변수를 뽑아 별도로 선언했을 때 오류가 발생한다면 타입 선언을 추가해야한다.
  • 변수가 정말로 상수라면 상수 단언을 사용해야 한다. 상수 단언을 사용하면 오류가 정의한 곳이 아니라 사용한 곳에서 발생하므로 주의하자.

아이템 27. 함수형 기법과 라이브러리로 타입 흐름 유지하기

jQuery, UnderScore, Lodash, Ramda 같은 라이브러리들의 일부 기능(map, flatMap, filter, reduce)은 순수 자바스크립트로 구현되어 있다. 이들은 타입스크립트와 조합하면 더 빛을 발한다. 그 이유는 타입 정보가 그대로 유지되면서 타입 흐름(flow)이 계속 전달되도록 하기 때문이다.

타입스크립트는 서드파티 라이브러리를 사용하는 것이 무조건 유리하다. 타입 정보를 참고하며 작업할 수 있기 때문에 서브파티 라이브러리 기반으로 바꾸는 데 시간이 훨씬 단축되기 때문이다.

요약

  • 타입 흐름을 개선하고, 가독성을 높이며 명시적인 타입 구문의 필요성을 줄이기 위해 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋다.
profile
주니어 프론트엔드 개발자가 되고 싶습니다!

0개의 댓글