TypeScript 2장(11~15)

이종서·2022년 11월 1일
1

TypeScript

목록 보기
2/9

Item 11. 잉여 속성 체크의 한계 인지하기

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

interface User {
	name: string;
    age: number;
}

const u: User = {
	name: 'LeeJS',
    age: 28,
    tell: 1234 // 객체 리터럴은 알려진 속성만 지정할수 있으며 'User' 형식에 'tell'이(가) 없습니다.
}

변수에 타입을 선언함과 동시에 객체 리터럴로 만들게 되면 잉여 속성이 체크 됩니다.

const leejs = {
	name: 'LeeJS', // name: string;
    age: 28,       // age: number;
    tell: 1234     // tell: number;
}

const j: User = leejs

구조적 타이핑이라는 관점에서 봤을 때 해당 예시는 적절해 보입니다. (j 변수에 leejs 변수가 정상적으로 할당.)
해당 leejs 타입은 User 타입의 부분 집합을 포함하므로, User에 할당 가능하면 타입 체커도 통과합니다.(임시 변수를 도입)
두 예시 모두 할당 가능 검사를 통과했습니다. 그러나 잉여 속성 체크는 1번 예시만 동작하고 2번 예시는 동작하지 않습니다.

⭐️ 여기서 알려주는 부분은 할당 가능 검사잉여 속성 체크가 별도의 과정이라는 것입니다.

function join(user: User) {
	// ... something
}

join(j);

join({
  name: 'jong',
  agee: 30,    // 객체 리터럴은 알려진 속성만 지정할 수 있지만 'User' 형식에 'agee'가 없습니다. 'age'를 쓰려고 했습니까?
  gender: 'M', // 'User' 형식에 'gender'가 없습니다.
})

User타입이 된 j 변수는 구조적 타이핑 관점에서 함수에 인자로 넘길 수 있습니다.
❗️ 그러나 새롭게 객체 리터럴로 만든 변수는 잉여 속성 체크에 의해 에러가 발생합니다.
따라서, 잉여 속성 체크는 특정 타입에 객체 리터럴을 생성하여 할당할 때 동작 하는것을 알 수 있습니다.

잉여 속성 체크는 필요한 속성 이외의 속성들을 체크하기 때문에 엄격한 객체 리터럴 체크라고도 불립니다.

잉여 속성 체크를 하지 않는 경우

  • 잉여 속성 체크는 타입 단언문을 사용할 때 적용되지 않습니다.
const userLee = { name: 'lee', age: 12, tell: 1234 } as User; // 정상
  • 인덱스 시그니처를 사용할 때 적용되지 않습니다.
interface Options {
	mode: boolean;
    [ortherOptions: string]: unkown;
}
const o: Options = { mode: true }; // 정상

선택적 속성만 가지는 '약한(weak)'타입

interface ListOptions {
  title?: string;
  text?: string;
  image?: string;
}

const opts = { images: 'img url' };
const o: ListOptions = opts;
	// ~ '{ images: 'img url' }' 유형에 'ListOptions' 유형과 공통적인 속성이 없습니다. 

구조적 관점에서 ListOptions 타입은 모든 속성이 선택적이므로 모든 객체를 포함할 수 있습니다.
이런 약한 타입에 대해서는 타입스크립트는 값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크를 수행합니다.(공통 속성 체크)

공통 속성 체크

  • 오타를 잡는데 효과적입니다.
  • 구조적으로 엄격하지 않습니다.
  • 약한 타입과 관련된 할당문마다 수행합니다.
  • 임시 변수를 제거하더라도 공통 속성 체크는 여전히 동작합니다.

요약
📌 객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달 할 때 잉여 속성 체크가 수행됩니다.
📌 잉여 속성 체크에는 한계가 있습니다. 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다는 점을 기억해야 합니다.

Item 12. 함수 표현식에 타입 적용하기

  1. 함수 '문장(statement)'
function user1(id: number): number {/* ... */} // 문장
  1. 함수 '표현식(expression)'
const user2 = function(id: number): number {/* ... */} // 표현식
const user3 = (id: number): number => {/* ... */} // 표현식

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

⭐️ 함수 타입으로 선언하여 함수 표현식에 재사용

type UserJoin = (id: number) => number;
const user: UserJoin = id => {/* ... */};

장점

  • 함수 타입의 선언은 불필요한 코드의 반복을 줄입니다.(반복되는 함수 시그니처를 하나의 함수 타입으로 통합)
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;
  • 함수 구현부도 분리되어 있어 로직이 보다 분명해집니다.(반환 타입까지 선언)

⭐️ 다른 함수의 시그니처를 참조하려면 typeof fn을 사용

declare function fetch(
	input: RequestInfo, init?: RequestInit
): Promise<Response>;

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의 타입을 추론할 수 있게 해 줍니다.
❗️ throw 대신 return을 사용했다면, 타입스크립트는 그 실수를 잡아냅니다.

요약
📌 매개변수나 반환 값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋습니다.
📌 같은 타입 시그니처를 반복적으로 사용할 경우 함수 타입을 분리해 내거나 이미 존재하는 타입을 확인합니다.
📌 다른 함수의 시그니처를 참조하려면 typeof fn을 사용합니다.

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

type TState = {
	name: string;
    capital: string;
}

interface IState{
	name: string;
    capital: string;
}

공통점

  1. 추가 속성과 함께 할당한다면 동일한 오류가 발생합니다.
const wyoming: TState = {
	name: 'Lee',
    capital: 'che',
    population: 500
//  ~~~~~~~~~~~~~~~ ... 형식은 'TState' 형식에 할당할 수 없습니다.
//					개체 리터럴은 알려진 속성만 지정할 수 있으며
//					'TState' 형식에 'population' 이 없습니다.	
}
  1. 인덱스 시그니처는 인터페이스와 타입에서 모두 사용할 수 있습니다.
type TDict = { [key: string]: string };
interface IDict {
	[key: string]: string;
}
  1. 함수 타입도 인터페이스나 타입으로 정의할 수 있습니다.
type TFn = (x: number) => string;
interface IFn {
  (x: number): string;
}

const toStrT: Tfn = x => '' + x; // 정상
const toStrI: IFn = x => '' + x; // 정상
  1. 타입 별칭과 인터페이스는 모두 제너릭이 가능합니다.
type TPair<T> = {
  first: T;
  second: T;
}
interface IPair<T> {
  first: T;
  second: T;
}
  1. 인터페이스 타입을 확장할 수 있으며, 타입은 인터페이스를 확장할 수 있습니다.
interface IStateWithPop extends TState {
  population: number;
} 
type TStateWithPop = IState & {population: number;};

❗️ 인터페이스는 유니온 타입 같은 복잡한 타입을 확장하지는 못합니다. 복잡한 타입을 확장하고 싶다면 타입과 &를 사용해야 합니다.

  1. 클래스를 구현(implements)할 때는, 타입과 인터페이스 둘 다 사용할 수 있습니다.
class StateT implements TState {
	name: string = '';
    capital: string = '';
}
class StaetI implements IState {
  	name: string = '';
  	capital: string = '';
}

차이점

  1. 유니온 타입은 있지만 유니온 인터페이스라는 개념은 없습니다.
type AorB = 'a' | 'b';
  1. 인터페이스에는 '보강(augment)'이 가능합니다. (* 타입은 기존타입에 추가적인 보강이 없는 경우에만 사용해야 합니다.)
interface IState {
  name: string;
  capital: string;
}
interface IState {
  population: number;
}
const wyaming: IState = {
  name: 'Lee',
  capital: 'Che',
  populataion: 500
} // 정상

위에 예제처럼 속성을 확장하는 것을 '선언 병합(declaration merging)'이라고 합니다.

복잡한 타입이라면 고민할 것도 없이 타입별칭을 사용하는 것이 좋습니다.
타입과 인터페이스 모두 표현 할 수 있는 간단한 객체 타입이라면 "일관성과 보강의 관점"에서 고려해 봐야 합니다.
그러나 프로젝트 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계입니다. 따라서, 그럴 때는 타입을 사용해야 합니다.

Item 14. 타입 연산과 제너릭 사용으로 반복 줄이기

DRY(don't repeat yourself) 원칙 : 같은 코드를 반복하지 말라는 뜻입니다.

  • 반복을 줄일는 가장 간단한 방법은 타입에 이름을 붙이는 것입니다.
function distance(a: {x: number, }, b: {x: number, y: number}) {
  return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}

// 위에 코드에 이름을 붙일 경우
interface Point2D {
  x: number;
  y: number;
}

function distance(a: Point2D, b: Point2D){ /* .... */ }
  • 한 인터페이스가 다른 인터페이스를 확장하게 해서 반복을 제거할 수 있습니다.
interface Person {
  firstName: string;
  lastName: string;
}
interface PersonWithBirthDate extends Person {
  birth: Date;
}

이미 존재하는 타입을 확장하는 경우에, 일반적이지는 않지만 인터섹션 연산자(&)를 쓸 수도 있습니다.
(유니온 타입(확장할 수 없는)에 속성을 추가하려고 할 때 유용합니다.)

type PersonWithBirthDate = Person & {birth: Date};
  • 값의 형태에 타입을 정의할때 keyof를 사용하면 좋습니다.
const INIT_OPTIONS = {
  width: 100,
  height: 100,
  color: '#ffffff',
  label: 'VGA',
};
type Options = typeof INIT_OPTIONS;

표준 라이브러리

1. Pick

  • 확장하여 구성하기 보다는 부분 집합으로 정의하는 방법이 있습니다.
interface State {
  id: string;
  title: string;
  file: string[];
  contents: string;
}

type TopNavState = {
  id: State['id'];
  title: State['title'];
  file: State['file'];
}

// 위에 방식에서 '매핑된 타입'을 사용하면 좀더 효율적입니다.

type TopNavState = {
  [k in 'id' | 'title' | 'file']: State[k]
};

// Pcik을 사용할 경우
type ToNavState = Pick<State, 'id' | 'title' | 'file'>;

⭐️ 매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식입니다. 이 패턴은 표준 라이브러리에서 Pick 이라고 합니다.

type Pick<T, K> = { [k in K]: T[k] };

2. Partial
생성하고 난 다음에 업데이트가 되는 클래스를 정의한다면,
update 메서드 매개변수의 타입은 생성자와 동일한 매개 변수이면서, 타입 대부분이 선택적 필드가 됩니다.

interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}
interface OptionsUpdate {
  width?: number;
  height?: number;
  color?: string;
  label?: string;
}
class UIWidget {
  constructor(init: Options) {/* ... */}
  update(opctions: OptionsUpdate) {/* ... */}
}

// 매핑된 타입과 keyof를 사용
type OptionUpdate = {[k in keyof Options]?: Options[k]} // keyof는 타입을 받아서 속성 타입의 유니온을 반환합니다.

// Partial 사용할 경우
class UIWidget {
  constructor(init: Options) {/* ... */}
  update(opctions: Partial<Options>) {/* ... */}
}

3. ReturnType

함수나 메서드의 반환 값에 명명된 타입을 만들고 싶을 수도 있습니다.

function getInfo(id: string) {
  //...
  return {
    id,
    name,
    age,
    height,
    width
  };
}
// 추론된 반환 타입은 {id: string, name: string, age: number, ...}


// 이때는 조건부 타입이 필요합니다. 이런 경우 ReturnType 제너릭이 정확히 들어맞습니다.
type UserInfo = ReturnType<typeof getInfo>;

❗️ 이런 기법은 신중하게 사용해야 합니다. 적용 대상이 값인지 타입인지 정확히 알고, 구분해서 처리해야 합니다.

제너릭 타입

제너릭 타입은 타입을 위한 함수와 같습니다.
제너릭 타입에서 매개변수를 제한할 수 있는 방법은 extends를 사용하는 것입니다.

interface Name {
  first: string;
  last: string;
}
type User<T extends Name> = [T, T];

const couple1: User<Name> = [
  {first: 'JS', last: 'Lee'},
  {first: 'HS', last: 'G'}
]; // OK

const couple2: User<{first: string}> = [
  {first: 'Sonny'},
  {first: 'Cj'}
]; // 'Name' 타입에 필요한 'last' 속성이 '{first: string;}' 타입에 없습니다.

위에 나온 Pick 의 정의는 extends를 사용해서 완성할 수있습니다.
타입 체커를 통해 기존 예제를 실행해 보면 오류가 발생합니다.

type Pick<T, K> = {
  [k in K]: T[k]
  // ~ 'K' 타입은 'string | number | symbol' 타입에 할당할 수 없습니다.
}

// K는 실제로 T의 키의 부분 집합, 즉 keyof T가 되어야 합니다.
type Pick<T, K extends keyof T> = {
  [k in K]: T[k]
}; // 정상

⭐️ 타입이 값의 집합이라는 관점에서 생각하면 extends를 '확장'이 아니라 '부분 집합' 이라는 걸 이해하면 됩니다.

Item 15. 동적 데이터에 인덱스 시그니처 사용하기

타입스크립트에서는 타입에 '인덱스 시그니처'를 명시하여 유연하게 매핑을 표현할 수 잇습니다.

type Rocket = {[property: string]: string}; // 인덱스 시그니처
const rocket: Rocket = {
  name: 'Lee 9',
  variant: 'v1.0',
  thrust: '4,940 kN'
} // 정상

인덱스 시그니처

  • 키의 이름: 키의 위치만 표시하는 용도. 타입 체커에서는 사용하지 않습니다.
  • 키의 타입: string이나 number 또는 symbol의 조합이어야 하지만, 보통은 string을 사용합니다.
  • 값의 타입: 어떤 것이든 될수 있습니다.

단점

  • 잘못된 키를 포함해 모든 키를 허용합니다. (ex. name 대신 Name 으로 작성해도 유효)
  • 특정 키가 필요하지 않습니다. (ex. {}도 유효한 Rocket 타입입니다.)
  • 키마다 다른 타입을 가질 수 없습니다. (ex. name는 string이나 thrust에 number여야 할수도 있습니다.)
  • 키는 무엇이든 가능하기 때문에 자동 완성 기능이 동작하지 않습니다.

위에 예제를 인터페이스로 변경할 경우

interface Rocket {
  name: string;
  variant: string;
  thrust_kN: number;
}
const falconHeavy: Rocket = {
  name: 'Lee 27',
  variant: 'v1',
  thrust_kN: 4,940
} // 정상
  • 인터페이스로 변경할 경우 모든 필수 필드가 존재하는지 확인합니다.
  • 타입스크립트에서 제공하는 언어 서비스를 모두 사용할 수 있습니다.(자동완성, 정의로 이동, 이름 바꾸기 등)

❗️ 인덱스 시그니처는 동적 데이터를 표현할 때 사용합니다.

연관 배열(associative array)의 경우, 객체에 인덱스 시그니처를 사용하는 대신 Map 타입을 사용하는 것을 고려할 수 있습니다.
이는 프로토타입 체인과 관련된 유명한 문제를 우회합니다.

어떤 타입에 가능한 필드가 제한되어 있는 경우라면 인덱스 시그니처로 모델링하지 말아야 합니다.
=> 선택적 필드 또는 유니온 타입으로 모델링 대체.

interface Row1 { [column: string]: number } // 너무 광범위함
interface Row2 {a: number; b?: number; c?: number; d?: number} // 최선
type Row3 = 
   | { a: number; }
   | { a: number; b: number; }
   | { a: number; b: number; c: number; }
   | { a: number; b: number; c: number; d: number}; // 가장 정확하지만 사용하기 번거로움

string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있는 경우

  1. Record를 사용하는 방법
    (키 타입에 유연성을 제공하는 제너릭 타입입니다. string의 부분집합으로 사용할 수 있습니다.)
type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
//	x: number;
//	y: number;
//	z: number;
// }
  1. 매핑된 타입을 사용하는 방법
    (매핑된 타입은 키마다 별도의 타입을 사용하게 해줍니다.)
type Vec3D = {[k in 'x' | 'y' | 'z']: number};
// Type Vec3D = {
// 	x: number;
//  y: number;
//  z: number;
//}

type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
// Type ABC = {
// 	a: number;
//  b: string;
//  c: number;
//}

요약
📌 런타임 때까지 객체의 속성을 알 수 없을 경우에만 인덱스 시그니처를 사용합니다.
📌 안전한 접근을 위해 인덱스 시그니처의 값 타입에 undefined를 추가하는 것을 고려해야 합니다.
📌 가능하다면 인터페이스, Record, 매핑된 타입 같은 인덱스 시그니처보다 정확한 타입을 사용하는 것이 좋습니다.

profile
프론트엔드 개발자

0개의 댓글