TypeScript

이펙티브 타입스크립트- 2장 타입시스템 (2)

devSoo 2022. 3. 29. 00:56

아이템 10. 객체 래퍼 타입 피하기 

자바스크립트의 기본형 타입들(string, number, boolean, symbol, bigint)는 해당 타입과 관련한 메서를 간편하게 사용할 수 있게 해주는 Wrapper 객체와 함께 사용되곤 한다. 

'primitive'.charAt(3); // 'm'

chatAt은 string의 메서드가 아님에도 쓸 수 있다 -> 객체래퍼가 동작하기 때문이다. 

그 과정은 문자열 기본형은 String 객체로 wrapping 되고 chatAt 메서드를 호출한 후에 마지막에 wrapping한 객체를 다시 버린다.

 

하지만 string 기본형과 String 객체 래퍼가 항상 동일하게 동작하지는 않다. 

String 객체는 오직 자기 자신하고만 동일하다

'hello' === new String('hello') // false
new String('hello') === new String('hello') // false

 

여기서 유의할 점은 타입스크립트가 기본형과 객체 래퍼 타입을 별도로 모델링 한다는 점이다.

string을 String이라고 잘못 타이핑 하더라도 잘 동작하는 것처럼 보인다.

// String으로 매개변수 타입을 받는 경우
function getStringLen(foo: String) {
    return foo.length;
}

getStringLen('hello'); // 정상
getStringLen(new String('hello')); // 정상

그러나 string을 매개변수로 받는 메서드에 String 객체를 전달하느 순간 문제가 발생한다.

function isGreeting(phrase: String) {
    return ['hello', 'good day'].includes(phrase); // 오류
    // 'String' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없습니다.
    // 'string'은(는) 기본 개체이지만 'String'은(는) 래퍼 개체입니다.
    // 가능한 경우 'string'을(를) 사용하세요.
    
    // ...
}

string은 String에 할당할 수 있지만 String은 string에 할당할 수 없기 때문이다.

 

결론적으로 기본형 타입을 객체 래퍼에 할당하는 구문을 오해하기 쉽고 굳이 그렇게 할 필요도 없기에 기본형 타입을 사용하는 것이 낫다.

 


요약 

  • 기본형 값에 메서드를 제공하기 위해 객체 래퍼 타입이 어떻게 쓰이는 지 이해해야 함(직접 사용하거나 인스턴스를 생성하는 것은 피해야함)
  • 타입스크립트 객체 래퍼 타입은 지양하고 대신 기본형 타입을 사용해야 한다. (String 대신 string, Number 대신 number, Boolean 대신 boolean, Symbol 대신 symbol, BigInt 대신 bigint를 사용해야 한다.)

아이템 11. 잉여 속성 체크의 한계 인지하기 

타입이 명시된 변수에 객체 리터럴을 할당할 때 타입스크립트는 해당 타입의 속성이 있는지 그리고 그 외의 속성은 없는지 체크한다. 

 

interface Room {
 numDoors: number;
 ceilingHeightFt: number;
}

const r:Room = {
 numDoors: number;
 ceilingHeightFt: number;
 elephant: 'present', // 오류 객체 리터널은 알려진 속성만 지정할 수 있으며 Room 형식에 elephant가 없습니다
}

근데 아이템 4에서 배웠던 구조적 타이핑의 관점으로 생각해보면 오류가 발생하지 않아야 한다. 아래의 예시를 보자.

const obj = {
 numDoors: number;
 ceilingHeightFt: number;
 elephant: 'present', 
}

const r:Room = obj // 정상

obj 타입은 Room 타입의 부분 집합을 포함하므로 Room에 할당 가능하며 타입 체커도 통과한다.

위 두 예시의 차이점은 첫 번째 예시는 구조적 타입 시스템에서 발생할 수 있는 오류를 잡을 수 있도록 '잉여속성체크' 라는 과정이 수행 됐다. 

하지만 잉여속성체크 역시 조건에 따라 동작하지 않는 등 한계가 존재한다.

 

타입스크립트는 런타임에 예외를 던지는 코드에 오류를 표시하는 것뿐 아니라, 의도와 다르게 작성된 코드까지 찾으려 한다. 

 

interface Options {
    title: string;
    darkMode?: boolean;
}

const o: Options = { darkmode: true, title: 'Ski Free' };
// 잉여 속성 체크가 동작, 오류
// 'Options' 형식에 'darkmode'이(가) 없습니다.

const intermediate = { darkmode: true, title: 'Ski Free' };
const op: Options = intermediate;
// 잉여 속성 체크가 동작하지 않음, 정상
// intermediate 변수에 집어넣은 후에 Options 타입에 할당해줬는데
// intermediate는 객체 리터럴이 아니기 때문에 잉여 속성 체크가 동작하지 않는다.

const opt = { darkmode: true, title: 'Ski Free' } as Options;
// 잉여 속성 체크가 동작하지 않음, 정상
// 타입 단언문을 사용할 때도 잉여 속성 체크가 동작하지 않는다.
// 이 역시, 타입 단언문 대신 타입 선언을 사용해줘야 하는 이유 중 하나.

 

만약 잉여 속성 체크를 원치 않는다면, 인덱스 시그니처를 사용해 타입스크립트가 추가적인 속성을 예상하도록 할 수 있다.

interface Options {
    darkMode?: boolean;
    [otherOptions: string]: unknown;
}

const o: Options = { darkmode: true }; // 정상. 잉여 속성 체크가 동작하지 않음

 

객체 리터럴을 변수에 할당하거나 함수의 매개변수로 전달할 때 '잉여 속성 체크'가 수행된다. 잉여 속성 체크는 구조적 타이핑 시스템에서 허용되는 프로퍼티명의 오타와 같은 실수를 잡는 데에 효과적인 방법이다.
하지만 임시 변수를 도입하면 잉여 속성 체크를 뛰어넘을 수 있다는 점은 한계로 작용된다.

 

인터페이스의 모든 속성이 Optional한 경우를 약한 타입이라고 한다.

이 경우 잉여 속성 체크와 유사하게 공통된 속성이 있는지 검사하는 별도의 체크를 수행한다.

하지만 약한 타입과 관련된 모든 할당문 마다 수행된다는 점이 다르다(객체 리터럴이어도 안됨). 

interface Options {
    logscale?: boolean;
    invertedYAxis?: boolean;
    areaChart?: boolean;
}

const opts = { logScale: true };
const o: Options = opts;
// 오류. '{ logScale: boolean; }' 유형에 'Options' 유형과 공통적인 속성이 없습니다.

 

요약 

  • 객체 리터럴을 변수에 할당하거나 함수에 매개 변수로 전달할 때 잉여 속성 체크가 수행됨
  • 잉여 속성 체크는 오류를 찾는 효과적인 방법이나 타입스크립트 타입 체커가 수행하는 일반적인 구조적 할당 기능성 체크와 역할이 다름
  • 잉여 속성 체크에는 한계가 있음. 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다는 점을 유의

아이템 12. 함수 표현식에 타입 적용하기

자바스크립트(타입스크립트) 에서는 함수 문장(statment)과 함수 표현식(expression)을 다르게 인식한다.

function rollDice(sides:number):number {} //문장
const rollDice2 = function(sides:number):number {} //표현식
const rollDice3 = (sides:number):number => {}  // 표현식

 

타입스크립트에서는 함수 표현식(expression)을 사용하는 것을 권장한다. 

함수의 매개변수부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다는 장점이 있기 때문이다.

type DiceRollFn = (sides: number) => number;
const rollDice: DiceRollFn = sides => { /* ... */ };
fuction add(a:number, b:number) { return a + b };
fuction sub(a:number, b:number) { return a - b };
fuction mul(a:number, b:number) { return a * b };
fuction div(a:number, b:number) { return a / b };

위와 같이 반복되는 함수 시그니처는 아래와 같이 하나의 함수 타입으로 통합할 수 있다.

type BinaryFn = (a: number, b: number) => number;

const add: BinaryFn = (a, b) => a + b;
const sub: BinaryFn = (a, b) => a - b;
const mul: BinaryFn = (a, b) => a * b;
const div: BinaryFn = (a, b) => a / b;

여러 라이브러리들에서는 이를 활용하여 공통 함수 시그니처를 타입으로 제공한다. 

EX) 리액트 에서는 함수의 매게변수에 명시하는 MouseEvent 타입 대신에 함수 전체에 적용할 수 있는 MouseEventHandler 타입을 제공한다. 

 

공통 함수 시그니처 타입이 존재하더라도 함수 표현식에 별도의 타입을 적용할 수 있다.

// lib.dom.d.ts에 선언된 fetch 타입 선언
declare function fetch(
    input: RequestInfo, init?: RequestInit
): Promise<Response>;

 

fetch에서 발생할 수 있는 버그 중에 하나는, 만약 API 주소에 존재하지 않는 API를 넣었다면

응답으로 '404 Not Found'가 포함된 내용을 응답한다.. 이런 경우에 응답은 JSON 형식이 아닐 수 있다..

문제는 이렇게 되면 response.json()은 '404 Not Found'가 아니라 '응답이 JSON이 아니다'라는 새로운 오류 메시지를 담아서 거절된 Promise를 반환하고 그래서 원래 발생했던 실제 오류인 '404 Not Found'는 감춰지게 된다.

 

그래서 다음과 같이 상태 체크를 수행해 줄 checkedFetch 함수를 작성해볼 수 있다.

async function checkedFetch(input: RequestInfo, init?: RequestInit) {
    const response = await fetch(input, init);
    if (!response.ok) {
        // 비동기 함수 내에서 거절된 Promise로 변환
        throw new Error('Request failed: ' + response.status);
    }
    return response;
}

하지만 "typeof fn"를 사용하면 다음과 같이 훨씬 간결하게 작성할 수 있다.

const checkedFetch: typeof fetch = async (input, init) => {
    const response = await fetch(input, init);
    if (!response.ok) {
        throw new Error('Request failed: ' + response.status);
    }
    return response;
}

함수 선언문을 함수 표현식으로 바꿨고, 함수 전체에 타입(typeof fetch)를 적용했다.

이는 타입스크립트가 매개변수인 input과 init의 타입을 추론할 수 있게 해준다.

 

정리하자면, 함수의 매개변수에 각각 타입 선언을 해주는 것보다

함수 표현식 전체 타입을 정의하는 것이 코드도 간결하고 더 안전하다.


요약 

  • 매개변수나 반환 값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋다.
  • 같은 타입 시그니러를 반복적으로 작성한 코드는 함수 타입을 분리해 내거나 이미 존재하는 타입을 찾아본다.
  • 다른 함수의 시그니처를 참조하려면 typeof fn을 사용하면 된다.

아이템 13. 타입과 인터페이스의 차이점 알기

타입스크립트에서 타입을 정의하는 방법은 타입 별칭((Type aliases)와 인터페이트(Interface) 두 가지가 있다.

 

대부분의 경우 타입과 인터페이스는 차이 없이 쓸 수 있다. 하지만 분명히 존재하는 차이를 알고 일관성을 유지해야 한다. 

둘의 공통점과 차이점을 알아보자.

 

공통점

  • 인덱스 시그니처는 둘 다 모두 사용할 수 있다.
type TDict = { [key: string]: string };
interface IDict {
    [key: string]: string
}
  • 함수 타입도 인터페이스나 타입으로 정의할 수 있다.
type TFn = (x: number) => string;
interface Ifn {
    (x: number): string;
}
  • 모두 제네릭 사용이 가능하다.
type TPair<T> = {
    first: T;
    second: T;
}
interface IPair<T> {
    first: T;
    second: T;
}

차이점

  • 인터페이스는 타입을 확장할 수 있고 타입(Type aliases)도 인터페이스를 확장할 수 있지만 인터페이스는 유니온 타입 같은 복잡한 타입을 확장하지 못한다. 
    (클래스를 구현할 때는 타입과 인터페이스 둘 다 사용할 수 있다.)
  • 튜플 타입이나 배열 타입은 인터페이스 보다 type 키워드로 훨씬 간단하게 선언할 수 있다.
    (게다가 인터페이스에서는 튜플에서 사용할 수 있는 concat같은 메서드를 사용할 수 없음)
  • 인터페이스에는 타입에 없는 '선언 병합(declaration merging)' 기능이 존재한다.
// interface
interface Tuple {
    0: number;
    1: number;
    length: 2;
}
const t: Tuple = [10, 20]; // 정상

// type aliases
type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];

타입 선언에는 사용자가 채워야 하는 빈틈이 있을 수 있기 때문에 타입 선언 파일 작성 시 선언 병합을 지원하기 위해 인터페이스 사용하는 것이 좋다.


요약 

  • 타입과 인터페이스의 차이점과 비슷한 점을 이해해야 됨 
  • 한 타입을 type과 interface 두 가지 문법을 사용해서 작성하는 방법을 터득해야 함
  • 프로젝트에서 어떤 문법을 사용할 지 결정할 때 한 가지 일관된 스타일을 확립하고 보강 기법이 필요한지 고려해야함