본문 바로가기

공부

[이펙티브 타입스크립트] 3장 타입 추론

타입 추론은 수동으로 명시해야 하는 타입 구문의 수를 줄여주기 때문에 코드의 전체적인 안정성이 향상됩니다. 
즉, 불필요한 타입은 사용하지 말아야 합니다.

 

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

타입스크립트에서 추론이 가능한 변수에는 타입을 선언하지 않는 것이 좋습니다.

let x:number = 12; //비생산적
let x = 12; // 타입스크립트에서 타입 추론 가능

더 복잡한 객체도 추론이 가능합니다.

const person = {
  name: "Hyeon",
  born: {
    where: "Seoul",
    when: "long time ago",
  },
}

함수의 반환에도 타입을 명시하여 오류를 방지할 수 있습니다.

반환 타입을 명시한다면 정확한 위치에 오류가 표시될 뿐만 아니라 함수에 대해 명확하게 알 수 있고 명명된 타입을 사용할 수 있습니다.

interface Vacter2D { x: number, y: number }

function add(a: Vacter2D, b; Vacter2D) {return {x: a.x + b.x, y: a.y + b.y }};
// add(Vacter2D, Vavter2D) : {x: number, y: number} 
// return 타입이 Vacter2D와 동일한데 동일한지 아닌지 한번에 확인하기 어렵다.​

 

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

const를 주로 쓴다면 크게 해당되지 않는 아이템인 것 같다.

let id = '12-34-56'; // string;

id = 123456; //number;
//Type 'number' is not assignable to type 'string'.

타입스크립트는 이미 위에서 '12-34-56' 값을 보고 id의 타입을 string으로 추론 하기 때문에 아래 id에서 number의 값을 할당할 수 없어 오류가 나타납니다.

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

 

const id = '12-34-56';

const serial = 123456;

타입이 바뀌는 변수는 되도록 피해야 하며, 목적이 다른 곳에는 위 처럼 차라리 별도의 변수를 도입하는 것이 낫습니다.

 

[ 아이템 21 ] 타입 넓히기

const mixed = ['x', 1]; // (string|number)[]

/*
('x'|1)[]
['x', 1]
[string, number]
readonly [string, number]
(string|number)[]
readonly (string|number)[]
[any, any]
any[]
*/

타입 넓히기는 타입을 명시하지 않은 경우 지정된 값을 가지고 할당 가능한 값들의 집합을 유추해야 한다는 뜻입니다.

const v = {
  x: 1,
}; 

v.x = 3; //정상
v.x = '3'; 
// ~ '"3"' 형식은 'number' 형식에 할당할 수 없습니다.
v.y = 4;
// ~ '{ x: number }' 형식에 'y' 속성이 없습니다.
v.name = 'Pythagoreas';
// ~~~ '{ x: number; }' 형식에 'name' 속성이 없습니다.

 

타입스크립트에서는 v의 타입을 추론할 때 let으로 할당된 것 처럼 다루기 때문에 타입은 {x: 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}

값 뒤에 as const를 작성하면, 타입스크립트는 최대한 좁은 타입으로 추론합니다. 

넓히기로 오류가 발생한다고 생각하면, 명시적 타입 구문 또는 const 단언 문을 추가하는 것을 고려해야 합니다.

let과 const를 혼동해서는 안되며 온전한 타입 상태의 const를 의미합니다.
변수와 객체에 사용되는 as const는 체킹하는 환경의 범위가 다르다고 생각하면 될 것 같습니다.

 

[ 아이템 22 ] 타입 좁히기

타입 좁히기는 타입스크립트가 넓은 타입으로부터 좁은 타입으로 진행하는 과정을 말합니다.

타입 좁히기 방법에는 에러 throw, instansceof, 태그 된 유니온, 사용자 정의 타입 가드 등의 방법으로 다양하게 타입 좁히기를 시도할 수 있습니다. 

 

타입스크립트는 일반적으로 조건문에서 타입을 좁히는 데 매우 능숙합니다.

그러나 타입을 섣불리 판단하는 실수를 저지르기 쉬우므로 다시 한번 꼼꼼히 따져 봐야 합니다. 

const el = document.getElementById('foo') // 타입이 HTMLElement | null
if (typeof el === 'object') {
  el; // 타입이 HTMLElemnet | null
}

자바스크립트에서 typeof null이 'object'이기 때문에, if 구문에서 null이 제외되지 않습니다. 

interface UploadEvent {
  type: 'upload'
  filename: string
  contents: string
}
interface DownloadEvent {
  type: 'download'
  filename: string
}
type AppEvent = UploadEvent | DownloadEvent

function handleEvent(e: AppEvent) {
  switch (e.type) {
    case 'download':
      e // Type is DownloadEvent
      break
    case 'upload':
      e // Type is UploadEvent
      break
  }
}

위 패턴은 태그된 유니온 또는 구별된 유니온이라고 불리며, 타입스크립트 어디에서나 찾아볼 수 있습니다. 

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

function getElementContent(el: HTMLElement) {
  if (isInputElement(el)) {
    el // Type is HTMLInputElement
    return el.value
  }
  el // Type is HTMLElement
  return el.textContent
}

export default {}

만약 타입스크립트가 타입을 식별하지 못한다면, 식별을 돕기 위해 커스텀 함수를 도입할 수 있는데 이러한 기법을 '사용자 정의 타입 가드'라고 합니다.

 

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

타입스크립트에서 객체를 생성할 때는 해당 객체에 속성을 하나씩 추가하기보다는 한 번에 객체를 만드는 것을 지향합니다.

const pt = {}
pt.x = 3
// ~ Property 'x' does not exist on type '{}'
pt.y = 4
// ~ Property 'y' does not exist on type '{}'

첫 번째 줄의 pt 타입은 {} 값을 기준으로 추론되기 때문에 속성을 추가할 수 없습니다. 그렇기에 위 방식보다는 아래 방식으로 객체를 만드는 것이 좋습니다.

interface Point {
  x: number
  y: number
}
const pt: Point = {
  x: 3,
  y: 4,
}

작은 객체들을 조합해서 큰 객체를 만들어야 하는 경우에도 여러 단계를 거치는 것은 좋지 않습니다. 

interface Point {
  x: number
  y: number
}
const pt = { x: 3, y: 4 }
const id = { name: 'Pythagoras' }
const namedPoint = {}
Object.assign(namedPoint, pt, id)
namedPoint.name
// ~~~~ Property 'name' does not exist on type '{}'

다음과 같이 '객체 전개 연산자' ...를 사용하면 큰 객체를 한 번에 만들 수 있습니다.

interface Point {
  x: number
  y: number
}
const pt = { x: 3, y: 4 }
const id = { name: 'Pythagoras' }
const namedPoint = { ...pt, ...id }
namedPoint.name // OK, type is string

타입에 안전한 방식으로 조건부 속성을 추가할 때는 null 또는 {}으로 객체 전개를 사용하면 됩니다. 

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

편집기에서 president에 마우스를 올려 보면, 타입이 선택적 속성을 가진 것을 확인할 수 있습니다. 

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

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

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

편집기에서 pharaoh에 마우스를 올려보면, 유니온 타입으로 추론되는 것을 확인할 수 있습니다. 

const pharaoh: {
  start: number;
  end: number;
  name: string;
  title : string;
 } | {
  name: string;
  title: string;
}

startend는 선택적 필드로 추론하지 못했기에 이 타입에서는 startend를 읽을 수 없습니다. 

선택적 필드를 위해서는 아래와 같이 헬퍼 함수를 사용하면 됩니다.

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

 

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

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지는 않고 값이 존재하는 문맥까지 살핍니다.

그런데 문맥을 고려해 타입을 추론하다 보면 가끔 이상한 결과가 나오는데 이때 문맥이 어떻게 사용되는지 이해하고 있다면 대처가 가능합니다.

function setLanguage(language: string) { /* ... */ } 

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

//참조 형태
let language = 'JavaScript'; 
setLanguage(language);     //정상

타입스크립트에서 코드의 동작과 실행 순서를 바꾸지 않으면서 표현식을 위 처럼 상수로 분리할 수 있습니다.

 

문자열 타입을 더 특정해서 문자열 리터럴 타입의 유니온으로 바꾼다고 가정했을 때 

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

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

//참조 형태
let language = 'JavaScript'; 
setLanguage(language);     //실패
            ~~~~~~~~  'string' 형식의 인수는 'Language' 형식의 매개변수에 할당될 수 없다.

타입스크립트는 값을 변수로 분리해내면, 할당 시점에 타입을 추론하기 때문에 에러가 발생합니다. 

 

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

 

첫 번째 - 변수에 타입 선언

//참조 형태
let language: Language = 'JavaScript'; 
setLanguage(language);     //성공

두 번째 - 변수 선언을 const로 변경

//참조 형태
const language = 'JavaScript'; 
setLanguage(language);     //성공

이 과정에서 문맥과 값을 분리를 했는데 추후에 근본적인 문제를 발생시킬 수 있습니다. 이러한 문맥 소실로 인해 오류가 발생하는 경우가 있습니다.

 

const 키워드와 as const 차이 이해

문자열 리터럴 타입과 마찬가지로 튜플 타입에서도 문제가 발생합니다.

// 매개변수는 (latitude, longitude) 쌍입니다.
function panTo(where: [number, number]) { /* ... */}

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

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

이전 예제의 두 번째 방법인 변수 선언을 const로 하여 해결하려 하였지만 타입스크립트는 loc의 타입을 '[number, number]'가 아닌 

number[]의 타입으로 추론하였습니다. 이유는 const는 값이 가리키는 참조가 변하지 않는 얕은(shallow) 상수이기 때문입니다.

 

값의 내부까지 상수라는 것을 타입스크립트에게 알려주기 위해 깊은(deeply) 상수인 as const로 변경해줍니다.

const loc = [10,20] as const;
panTo(loc);
      ~~~ 'readonly [10,20]' 형식은 'readonly'이며 변경 가능한 형식 '[number, number]'에 할당할 수 없다.

안타깝게도 as const는 너무 과할 정도로 타입이 정확하기에 readonly 구문을 추가해줘야 합니다.

function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10,20] as const;
panTo(loc); //정상

as const는 문맥 손실과 관련한 문제를 깔끔하게 해결할 수 있지만 오류는 타입 정의가 아니라 호출되는 곳에서 발생하기에 주의해야 합니다.

const loc = [10,20,30] as const; // 실제 오류는 여기서 발생
panTo(loc); 
      ~~~~ 'readonly [10, 20, 30]' 형식의 인수는 
           'readonly [number, number]' 형식의 매개변수에 할당될 수 없습니다.
           'length' 속성의 형식이 호환되지 않습니다.