Typescript

[Typescript] 이펙티브 타입스크립트 3 아이템23-25

sian han 2023. 3. 15. 15:31

※ 아이템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 = {
  x: 3,
  y: 4,
} // OK

해결 ! 근데 만약 반드시 나눠서 만들어야 한다면 단언문을 사용할 수 있다.

const pt = {} as Point
pt.x = 3
pt.y = 4 // OK

 

 

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

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 '{}'

'객체 전개 연산자' ... 를 사용하면 큰 객체를 한꺼번에 만들어 낼 수 있다.

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

 

객체 전개 연산자란 ?

chatGPT 답변  :

객체 전개 연산자(Object spread syntax)는 JavaScript의 문법 중 하나입니다. 이 연산자는 주로 객체나 배열의 요소를 전개하여 복사하거나 합치는 데 사용됩니다.

객체 전개 연산자는 ...으로 표시되며, 객체나 배열을 전개할 때 사용됩니다. 객체 전개 연산자를 사용하면 객체의 모든 속성을 다른 객체에 복사하거나, 기존 객체에 새로운 속성을 추가할 수 있습니다.

다음은 객체 전개 연산자를 사용하여 두 개의 객체를 병합하는 간단한 예시입니다.

const obj1 = {a: 1, b: 2};
const obj2 = {c: 3, d: 4};
const obj3 = {...obj1, ...obj2};
console.log(obj3); // {a: 1, b: 2, c: 3, d: 4}

위의 코드에서 ... 연산자를 사용하여 obj1과 obj2의 속성을 모두 포함하는 새로운 객체 obj3을 만듭니다.

객체 전개 연산자는 객체 뿐만 아니라 배열에서도 사용할 수 있습니다. 배열에서 전개 연산자를 사용하면 배열의 요소를 다른 배열에 복사하거나, 새로운 요소를 추가할 수 있습니다. 다음은 배열 전개 연산자를 사용하는 간단한 예시입니다.

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
const arr3 = [...arr1, ...arr2];
console.log(arr3); // [1, 2, 3, 4, 5, 6]

위의 코드에서 배열 전개 연산자를 사용하여 arr1과 arr2의 모든 요소를 포함하는 새로운 배열 arr3을 만듭니다.

객체나 배열 전개 연산자는 코드를 간결하게 작성하고 가독성을 높일 수 있으며, 자바스크립트에서 매우 유용한 문법 중 하나입니다.

 

이런 객체 연산자를 사용하면 타입 걱정 없이 필드 단위로 객체를 생성할 수도 있다. 

이때 모든 업데이트마다 새 변수를 사용해 각각 새로운 타입을 얻도록 하는 것이 중요하다.

interface Point {
  x: number
  y: number
}

const pt0 = {}
const pt1 = { ...pt0, x: 3 }
const pt: Point = { ...pt1, y: 4 } // OK

위 방법은 객체에 속성을 추가하고 타입스크립트가 새로운 타입을 추론할 수 있게 해 유용하다는데 특별히 유용한지 모르겠음

 

 

▶ 전개 연산자를 사용해 객체 생성하기

null 또는 {} 으로 객체 전개 사용하기

  - 타입에 안전한 방식으로 조건부 속성을 추가할 수 있다.

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

 

 

 

요약

  • 속성을 제각각 추가하지 말고 한꺼번에 객체로 만들어야한다. 안전한 타입으로 속성을 추가하려면 객체 전개({...a,...b}) 를 사용하면 된다.
  • 객체에 조건부로 속성을 추가하는 방법을 익혀야 한다.

 

 


 

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

const borough = { name: 'Brooklyn', location: [40.688, -73.979] }
const loc = borough.location

borough.location 배열에 loc 이라는 별칭을 줬다.

별칭의 값을 변경하면 원래 속성값에서도 변경된다.

loc[0] = 0 //별칭 값 변경
console.log(borough.location) //[ 0, -73.979 ]

 

 

별칭을 남발하면 제어흐름을 분석하기 어렵다는 것을 알려주는 예제 : 다각형

interface Coordinate {
  x: number
  y: number
}

interface BoundingBox {
  x: [number, number]
  y: [number, number]
}

interface Polygon {
  exterior: Coordinate[]
  holes: Coordinate[][]
  bbox?: BoundingBox
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  if (polygon.bbox) {
    if (pt.x < polygon.bbox.x[0] || pt.x > polygon.bbox.x[1] || 
    	pt.y < polygon.bbox.y[0] || pt.y > polygon.bbox.y[1]) {
      return false
    }
  }

  // ... more complex check
}

polygon 과 Coordinate 타입의 pt 를 매개변수로 받아서

polygon.bbox 가 true 이면서 

bbox 영역을 벗어난다면 false 를 응답한다. 

요약하자면 isPointInPolygon 함수는 어떤 점이 polygon 안에있는 점인지, 밖에 있는 점인지를 판단하는 것이다. 

이 코드는 타입체크도 통과하고 잘 동작하지만, 반복되는 부분이 존재한다 (polygon.bbox)

interface Coordinate {
  x: number
  y: number
}

interface BoundingBox {
  x: [number, number]
  y: [number, number]
}

interface Polygon {
  exterior: Coordinate[]
  holes: Coordinate[][]
  bbox?: BoundingBox
}
function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  polygon.bbox // Type is BoundingBox | undefined
  const box = polygon.bbox
  box // Type is BoundingBox | undefined
  if (polygon.bbox) {
    polygon.bbox // Type is BoundingBox
    box // Type is BoundingBox | undefined
  }
}

polygon.bbox 의 값은 정제했지만 box 는 그렇지 않았기 때문에 오류가 발생했다.

polygon.bbox : BoundingBox

box : BoundingBox | undefined

 

별칭을 일관성 있게 사용해 위 오류를 제거해보자

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox
  if (box) {
    if (pt.x < box.x[0] || 
    	pt.x > box.x[1] || 
        pt.y < box.y[1] || 
        pt.y > box.y[1]) {
      // OK
      return false
    }
  }
  // ...
}

이런 경우 타입체커 문제는 해결되었으나, 

box 와 bbox 가 같은 값인데 다른 이름을 사용하고 있어 문제가 남아있다.

객체 비구조화를 사용해 코드를 변경해보자 (구조분해할당)

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const { bbox } = polygon
  if (bbox) {
    const { x, y } = bbox
    if (pt.x < x[0] || 
    	pt.x > x[1] || 
        pt.y < x[0] || 
        pt.y > y[1]) {
      return false
    }
  }
  // ...
}

 

 

 

const polygon: Polygon = { exterior: [], holes: [] }
function calculatePolygonBbox(polygon: Polygon) {
  polygon.bbox = { x: [0,1], y: [2, 3]}
}

const { bbox } = polygon
if (!bbox) {
  calculatePolygonBbox(polygon) // Fills in polygon.bbox
  // Now polygon.bbox and bbox refer to different values!
  console.log(polygon.bbox, bbox); //{ x: [ 0, 1 ], y: [ 2, 3 ] } undefined
}

polygon.bbox 는 calculatePolygonBbox 함수에 의해서 생김

근데 bbox 는 처음에 구조분해 할당되어있는 애는 (const { bbox } = polygon) 여전히 undefined

 

그니까 ! calculatePolygonBbox 함수에 의해생긴 polygon.bbox 는 값이 있고,

구조분해할당해서 생긴 bbox 는 undefined 인 것임

 

 

요약

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

 

 


 

 

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

 

콜백지옥

// HIDE
function fetchURL(url: string, cb: (response: string) => void) {
  cb(url)
}
const url1 = '1'
const url2 = '2'
const url3 = '3'
// END
fetchURL(url1, function (response1) {
  fetchURL(url2, function (response2) {
    fetchURL(url3, function (response3) {
      // ...
      console.log(1)
    })
    console.log(2)
  })
  console.log(3)
})
console.log(4)

// Logs:
// 4
// 3
// 2
// 1

실행의 순서는 코드의 순서와 반대이다. 

이렇게 콜백이 중첩된 코드는 직관적으로 이해하기 어렵다. 

 

const page1Promise = fetch(url1)
page1Promise
  .then(response1 => {
    return fetch(url2)
  })
  .then(response2 => {
    return fetch(url3)
  })
  .then(response3 => {
    // ...
  })
  .catch(error => {
    // ...
  })

코드의 중첩도 적어졌고 실행순서도 코드 순서와 같아졌다. Promise.all 같은 기법을 사용하기도 쉬워졌다.

 

Promise.all 이란 ?

chatGPT 답변 :

 

Promise.all()은 자바스크립트의 Promise 객체를 활용하는 함수 중 하나입니다. 이 함수는 여러 개의 Promise 객체를 받아들이고, 이들이 모두 완료되면 그 결과를 배열로 반환하는 Promise 객체를 반환합니다. 이 때, 각각의 Promise 객체는 병렬적으로 처리됩니다.

Promise.all() 함수는 매우 유용한 함수 중 하나입니다. 예를 들어, 여러 개의 서버로부터 데이터를 가져와야 하는 경우에, 각각의 서버에서 데이터를 가져오는 작업을 병렬적으로 처리하여 시간을 단축할 수 있습니다. 또한, 여러 개의 파일을 동시에 업로드하거나, 여러 개의 API 요청을 병렬적으로 처리해야 하는 경우에도 Promise.all() 함수를 사용할 수 있습니다.

Promise.all() 함수는 다음과 같은 방식으로 사용할 수 있습니다.

Promise.all([promise1, promise2, promise3])
  .then(function(results) {
    // 이곳에서 결과를 처리합니다.
  })
  .catch(function(error) {
    // 이곳에서 에러를 처리합니다.
  });

위의 코드에서, Promise.all() 함수는 promise1, promise2, promise3 세 개의 Promise 객체를 받아들입니다. 이들 Promise 객체는 병렬적으로 처리됩니다. 이들 중 어느 하나라도 실패하면, 전체 Promise 객체는 실패하게 됩니다. 그러나, 모든 Promise 객체가 성공하면, Promise.all() 함수는 모든 결과를 배열로 반환합니다. 이 배열은 then() 함수에서 받아들여서 결과를 처리하게 됩니다. 만약, Promise.all() 함수의 실행 도중 에러가 발생하면, catch() 함수에서 해당 에러를 처리합니다

 


 

ES2017 에서는 async 와 await 키워드를 도입해 콜백지옥을 간단하게 처리할 수 있게 되었다.

await 키워드 : 각각의 프로미스가 처리(resolve) 될 때까지 fetchPages 함수의 실행을 멈춘다. 그리고 async 함수 내에서 await 중인 프로미스가 거절(reject) 되면 예외를 던진다. 

async function fetchPages() {
  try {
    const response1 = await fetch(url1)
    const response2 = await fetch(url2)
    const response3 = await fetch(url3)
    // ...
  } catch (e) {
    // ...
  }
}

타입스크립트는 런타임에 관계없이 async/await 를 사용할 수 있다. 

 

왜 콜백보다 Promise 혹은 async/await 를 사용해야할까 ?

  • 콜백보다는 프로미스가 코드를 작성하기 쉽다.
  • 콜백보다는 프로미스가 타입을 추론하기 쉽다.

예를들어 아래에서 타입스크립트는 세가지 response 변수 타입을 Response 로 추론한다.

async function fetchPages() {
  const [response1, response2, response3] = await Promise.all([fetch(url1), fetch(url2), fetch(url3)])
  // ...
}

 

 

또한 입력된 프로미스들 중 첫번째가 처리될 때 완료되는 Promise.race 도 타입추론과 잘 맞는다.

 

 

Promise.race 이란 ?

chatGPT 답변 :

 

 

Promise.race()는 여러 개의 Promise 중 하나가 먼저 완료될 때까지 기다리고 그 결과를 반환하는 메서드입니다. 이 메서드는 Promise 배열을 인자로 받아 가장 먼저 완료된 Promise의 결과나 reject reason을 반환합니다.

예를 들어, 다음과 같은 세 개의 Promise가 있다고 가정해보겠습니다.

const promise1 = new Promise((resolve, reject) => {
  setTimeout(resolve, 1000, 'promise1 resolved');
});

const promise2 = new Promise((resolve, reject) => {
  setTimeout(resolve, 2000, 'promise2 resolved');
});

const promise3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 3000, 'promise3 resolved');
});

이제 Promise.race()를 사용하여 이들 중 가장 먼저 완료된 Promise를 찾아보겠습니다.

Promise.race([promise1, promise2, promise3])
  .then(result => console.log(result))
  .catch(error => console.log(error));

위의 코드에서는 Promise.race()에 세 개의 Promise를 배열로 전달합니다. 이 메서드는 배열의 첫 번째 Promise인 promise1이 1초 후에 먼저 완료되기 때문에 promise1 resolved를 콘솔에 출력하게 됩니다. Promise.race()는 다른 Promise가 완료될 때까지 기다리지 않고, 가장 먼저 완료된 Promise의 결과를 반환합니다.

따라서 Promise.race()는 여러 개의 Promise 중 하나만이 성공하면 되는 경우에 유용합니다. 예를 들어, 여러 개의 API 요청 중 가장 빠른 응답을 받아야 하는 경우나, 타임아웃 기능을 구현해야 할 때 등에 활용할 수 있습니다.


프로미스를 사용하면 타입스크립트의 모든 타입 추론이 제대로 동작한다. 아래 예제를 보자

function timeout(millis: number): Promise<never> {
  return new Promise((resolve, reject) => {
    setTimeout(() => reject('timeout'), millis)
  })
}

async function fetchWithTimeout(url: string, ms: number) {
  return Promise.race([fetch(url), timeout(ms)])
}

타입 구문이 없어도 fetchWithTime 의 반환타입은 Promise<Response> 로 추론된다. 그 이유는 무엇일까 ?

Promise.race 의 반환타입 = Promise<Response | never> : 입력타입들의 유니온

never(공집합) 과의 유니온은 아무런 효과가 없다. 따라서 결과는 Promise<Response> 로 간단해진다.

 

 

일반적으로는 프로미스를 생성하기 보다는 async/await 를 사용해야 하는 이유는 다음과 같다

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

 

요약

  • 콜백보다는 프로미스를 사용하는게 코드 작성과 타입 추론 면에서 유리하다.
  • 가능하면 프로미스를 생성하기보다는 async 와 await 를 사용하는 것이 좋다. 간결하고 직관적인 코드를 작성할 수 있고 모든 종류의 오류를 제거할 수 있다.
  • 어떤 함수가 프로미스를 반환한다면 async 로 선언한는 것이 좋다.