본문으로 건너뛰기

· 약 4분
성은지

unknown

🔍 내용 (85쪽)

  • any 타입과 유사하지만 타입 검사를 강제하고 타입이 식별된 후에 사용될 수 있기 때문에 any 타입보다 더 안전하다.

🎉 결과

  • any보단 unknown을 사용하여 에러를 줄이자.

enum

🔍 내용 (98쪽)

  • 열거형은 타입스크립트 코드가 자바스크립트로 변환될 때 즉시 실행 함수(IIFE) 형식으로 변환되는 것을 볼 수 있다.
  • 이때 일부 번들러에서 트리쉐이킹 과정 중 즉시 실행 함수로 변환된 값을 사용하지 않는 코드로 인식하지 못하는 경우가 발생할 수 있다. 따라서 불필요한 코드의 크기가 증가하는 결과를 초래할 수 있다. 이러한 문제를 해결하기 위해 앞서 언급했던 const enum 또는 as const assertion을 사용해서 유니온 타입으로 열거형과 동일한 효과를 얻는 방법이 있다.
  • (enum 내에서 숫자로 값이 자동 추론된다는 내용 추가)

🎉 결과

  • 트리쉐이킹 과정 중 문제가 발생할 수 있는데, 이것이 성능에 막대한 영향을 미치진 않을 것이다.
  • enum 내에는 string만 넣는 것으로 한다.
  • enum을 사용하여 react-query key를 관리해보자. string 오탈자 입력을 방지하고 중복 키를 생성하는 문제를 해결할 수 있을 것이다.

템플릿 리터럴 타입(Template Literal Types)

🔍 내용 (105쪽)

type Stage =
| "init"
| "select-image"
| "edit-image";

type StageName = `${Stage}-stage`;

🎉 결과

  • redux action을 만들거나 css class name을 만들 때 유용하게 사용된다.
type Action = "increment" | "decrement";
type ActionType = `${Action}_ACTION`;
type ButtonType = "primary" | "secondary" | "danger";
type ButtonClass = `btn-${ButtonType}`;

기타

  • never를 사용하여 타입이 반환되지 않는 경우를 분명히 나타내자.
  • 여러 명이 하나의 파일을 동시에 수정할 것을 고려하여 함수명이 바뀔 때는 @deprecated로 표기하고 파일을 지우지 말자.
  • extends는 확장의 개념도 있지만, 추론한다는 뜻을 가지고 있다. 따라서 T extends true참이라면이라는 뜻이 있다.
  • 작은 타입을 Union Type이나 Intersection Type으로 묶어서 사용하자. Pick이나 Omit을 쓰면 어떤 값이 들어가고 나가는지 머릿속으로 계산해야 하기 때문에 가독성이 떨어진다.
  • ? 연산자로 타입을 만드는 대신 Partial을 쓰자.

· 약 12분
고현수

타입 확장의 장점과 더불어 extends, 교차 타입, 유니온 타입간의 차이를 파악하고 언제 사용하면 좋을지 살펴보자.
본문 p.120

타입 확장하기

타입 확장의 장점

  • 코드 중복을 줄일 수 있다.

    • DRY(Don't Repeat Yourself)는 타입에서도 적용된다.
  • 명시적인 코드를 작성할 수 있다.(어디에서 확장 되었다는 것을 쉽게 확인 가능하다.)

  • 코드 작성 단계에서 예기치 못한 버그도 예방 가능하다.

  • 예시

    • interface에서 확장

      interface Table {
      id: number;
      name: string;
      coordinate: string;
      dimension: string;
      }

      interface ModifyTable extends Table {
      color?: string;
      }
    • type을 활용한 확장

      type Table = {
      id: number;
      name: string;
      coordinate: string;
      dimension: string;
      };

      type ModifyTable = {
      color?: string;
      } & Table;
  • 타입 확장은 확장성이라는 장점을 가진다.

    • 여러 타입으로 확장 가능

      interface Table {
      id: number;
      name: string;
      coordinate: string;
      dimension: string;
      }

      interface ModifyTable extends Table {
      color?: string;
      }

      interface CheckConnectTable extends Table {
      connect?: boolean;
      occupy?: boolean;
      }

유니온 타입 (Union Type)

유니온 타입은 2개 이상의 타입을 조합하여 사용하는 방법이다. 집합의 관점으로 보면 유니온 타입을 합집합으로 해석할 수 있다.
p.122

type Union = A | B;

유니온 타입으로 선언된 값은 유니온 타입에 포함된 모든 타입이 공통으로 갖고있는 속성에만 접근할 수 있다.

예제

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

interface Humanoid {
id: string;
createdAt: string;
}

function getAge(object: Human | Humanoid) {
return object.age;
// Property 'age' does not exist on type 'Human | Humanoid'.
// Property 'age' does not exist on type 'Humanoid'.
}

교차 타입(Intersection Type)

교차 타입은 교집합의 개념과 비슷하다.
p.124

type Intersection = A & B;

예제

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

interface Humanoid {
id: string;
createdAt: string;
}

type NewWorldPeople = Human & Humanoid;

function getAge(object: NewWorldPeople) {
return object.age;
}

💡궁금하다 궁금해!

책의 본문에는 유니온 타입과 교차 타입에 대해서 이렇게 설명하고 있다.

타입스크립트의 타입을 속성의 집합이 아니라 값의 집합이라고 생각해야 유니온 타입이 합집합이라는 개념을 이해할 수 있다.
p.123

책에서는 속성이 아닌 값의 집합이라고 생각해야한다고 강조한다.(두 번) 하지만 어쨌든 직관적으로 동작하고 있는 모습은 Union 타입은 교집합처럼 동작하고 Intersection 타입은 합집합 처럼 느껴진다. 위에서 들었던 예제들을 보면 합집합이라니까 당연스레 object에 age가 있을 것으로 생각된다. 하지만 그렇지 않다. 도대체 왜이러는 걸까?

stackOverflow에 나와 똑같은 생각을 해서 질문을 올린 사람이 있는데 정말 여러 답변들이 달렸고 내가 제일 도움이 됐던 답변의 링크를 첨부한다.

이 분의 답변은 책에 적혀있는 속성의 집합이 아닌 값의 집합이라고 생각해야한다는 것의 의미를 잘 설명해주고 있다. 위의 Human과 Humanoid를 속성이 아닌 값의 관점에서 Union과 Intersection을 설명할 수 있다.

  • Union : Human의 값도 되고 Humanoid의 값도 된다. 그리고 Human과 Humanoid가 합쳐진 NewWorldPeople의 값도 된다.
  • Intersection : 오직 NewWorldPeople만 된다.

답변의 예시중 하나를 인용하고자한다.

type A = {
x: number;
y: number;
};

type B = {
y: number;
z: number;
};

type I = A & B;
type U = A | B;

let i: I;
let u: U;

i = { x: 1, y: 2 }; // <- error
i = { y: 2, z: 3 }; // <- error
i = { x: 1, y: 2, z: 3 };

u = { x: 1, y: 2 };
u = { y: 2, z: 3 };
u = { x: 1, y: 2, z: 3 };

타입 좁히기 - 타입가드

타입 가드는 크게 자바스크립트 연산자를 사용한 타입 가드와 사용자 정의 타입 가드로 구분할 수 있다.

  1. 원시 타입을 추론할 때 : typeof 연산자 활용하기
  2. 인스턴스화된 객체 타입을 판별할 때 : instanceof 연산자 활용하기
  3. 객체의 속성이 있는지 없는지에 따른 구분 : in 연산자 활용하기
  4. is 연산자로 사용자 정의 타입 가드 만들어 활용하기

자세한 내용은 책을 참조하기.

타입 좁히기 - 식별할 수 있는 유니온

종종 태그된 유니온으로도 불리우는 식별할 수 있는 유니온은 타입 좁히기에 널리 사용되는 방식이다.
p.139

이 예시는 본문의 예시를 그대로 인용한다. 왜냐하면 우리 코드에도 이와 같은 에러 처리 방식이 똑같이 적용 될 수 있기 때문이다.

type TextError = {
errorCode: string;
errorMessage: string;
};
type ToastError = {
errorCode: string;
errorMessage: string;
toastShowDuration: number;
};
type AlertError = {
errorCode: string;
errorMessage: string;
onConfirm: () => void;
};

type ErrorFeedbackType = TextError | ToastError | AlertError;
const errorArr: ErrorFeedbackType[] = [
{ errorCode: "100", errorMessage: "텍스트 에러" },
{ errorCode: "200", errorMessage: "토스트 에러", toastShowDuration: 3000 },
{ errorCode: "300", errorMessage: "얼럿 에러", onConfirm: () => {} },
{
errorCode: "999",
errorMessage: "잘못된 에러",
onConfirm: () => {},
toastShowDuration: 3000
} // expect error
];

타입을 이렇게 거창하게 작성한 이유는 아마도 errorCode 999를 가지는 저 객체에 대해서 에러를 기대했기 때문일 것이다. 그러나 이런 상황에서 자바스크립트는 덕 타이핑 언어이기 때문에 별도의 타입 에러를 뱉지 않는다.

식별할 수 있는 유니온

각 타입이 비슷한 구조를 가지지만 서로 호환되지 않도록 만들어 주기 위해서는 서로 포함 관계를 가지지 않도록 정의해야한다.
식별할 수 있는 유니온이란 타입 간의 구조 호환을 막기 위해 타입마다 구분할 수 있는 판별자를 달아주어 관계를 제거하는 것이다.

예제

type TextError = {
errorType: "TEXT";
errorCode: string;
errorMessage: string;
};
type ToastError = {
errorType: "TOAST";
errorCode: string;
errorMessage: string;
toastShowDuration: number;
};
type AlertError = {
errorType: "ALERT";
errorCode: string;
errorMessage: string;
onConfirm: () => void;
};

이렇게 수정하면 errorArr에 errorType이 포함되어 자바스크립트가 errorCode 999를 가진 객체에 대해 에러를 반환한다.

  // (생략)
{
type:"TEXT",
errorCode: "999",
errorMessage: "잘못된 에러",
onConfirm: () => {},
toastShowDuration: 3000
}
];

// Object literal may only specify known properties, and 'onConfirm' does not exist in type 'TextError'.

판별자 선정

  • 리터럴 타입이어야한다.
  • 인스턴스화할 수 있는 타입은 포함되지 않아야한다.

Exhaustiveness Checking

never 활용에 흔치 않은 예제라 정리해봤다.

모든 타입에 대한 타입 검사를 강제하고 싶다면 다음과 같이 코드를 작성하면 된다.
p.146-147

예제

type ProductPrice = "10000" | "20000" | "5000";

const getProductName = (productPrice: ProductPrice): string => {
if (productPrice === "10000") return "배민상품권 1만 원";
if (productPrice === "20000") return "배민상품권 2만 원";
// if(productPrice === "5000") return "배민상품권 5천 원"
else {
exhaustiveCheck(productPrice);
// Argument of type 'string' is not assignable to parameter of type 'never'.
// 'param' is declared but its value is never read.
return "배민상품권";
}
};

const exhaustiveCheck = (param: never) => {
throw new Error("type error!");
};

마무리

타입을 집합으로써의 개념으로 설명하는 것이 사실 이해가 잘 안됐었다. 하지만 '속성'이 아닌 '값'이라는 개념과 그것을 실마리 삼아 나름대로 이해를 할 수 있었다. 합집합과 교집합은 값으로써의 합집합과 교집합의 개념이다. 첨부한 stackoverflow의 링크에 자기 스스로 이해가 잘 되는 답변을 채택해서 다른 사람에게 설명할 수 있으면 될 것 같다.

타입 좁히기는 instanceof나 if문으로 값이 있는지 없는지 체크해서 막는 것이었는데 typeof나 in, is와 같은 자바스크립트 연산자를 활용한 타입 가드가 있다는 사실을 새롭게 배우게 되었다. 식별할 수 있는 유니온은 '이펙티브 타입스크립트'에서 본 개념이지만 실무에선 활용하지 못했었습니다. 그러나 Error 예제를 보고 나니 회사의 코드에 적용해 볼 수 있을 것 같다는 생각이 들었다.

마지막으로 never는 별로 실용적이거나 활용할 일이 없다고 생각했는데 챕터 마지막에 소개된 exhaustiveCheck를 never를 활용하기도 하는구나 하는 생각이 들었다. 예전에 Toast UI blog에서 타입스크립트의 Never 타입 완벽 가이드라는 글을 읽었던 적이 있는데, 사실 이게 뭐지? 라는 생각이 들었지만 이번에 실용적인 쓰임세를 알게되었다. 책을 다 읽었을 때 실무적으로 조금 더 완전하게 타입스크립트를 사용할 수 있을 것 같은 기대감이 생겼다.

· 약 4분
성은지

extends와 제너릭을 활용한 조건부 타입

🔍 내용 (151쪽)

  • PayMethod 타입은 제너릭 타입으로 extends를 사용한 조건부 타입이다.
type PayMethod<T> = T extends "card" ? Card : Bank;

🎉 이렇게 개선하면 어떨까요?

// 전
export type PointInfo = {
saleAmount: number;
saleProducts: string;
saleType: SaleType;
savePoint: number;
saveStamp: number;
type: string;
usePoint: number;
useStamp: number;
visitedAt: string;
};

// 후
type SaleType = "point" | "stamp";

type BasePointInfo<T extends SaleType> = {
saleAmount: number;
saleProducts: string;
saleType: T;
visitedAt: string;
};

type PointInfoExtended<T extends SaleType> = BasePointInfo<T> & {
type: T;
savePoint: T extends "point" ? number : 0;
usePoint: T extends "point" ? number : 0;
saveStamp: T extends "stamp" ? number : 0;
useStamp: T extends "stamp" ? number : 0;
};

// point 타입의 예시
const pointInfo: PointInfoExtended<"point"> = {
saleAmount: 100,
saleProducts: "Product A",
saleType: "point",
savePoint: 10,
usePoint: 20,
saveStamp: 0,
useStamp: 0,
type: "point",
visitedAt: "2024-03-14",
};

infer를 활용해서 타입 추론하기

🔍 내용 (156쪽)

  • extends를 사용할 때 infer 키워드를 사용할 수 있다. infer는 '추론하다'라는 의미를 지니고 있는데 타입스크립트에서도 단어 의미처럼 타입을 추론하는 역할을 한다.

🎉 예시

type ReturnType<T extends (...args: any) => any> = T extends (
...args: any
) => infer R
? R
: any;

함수 리턴 타입이 있어? 있으면 그거, 없으면 any

🔍 내용 (159쪽)

  • menuList에서 subMenus가 없는 MainMenunamesubMenus에서 쓰이는 name, route name에 동일한 문자열만 입력해야 한다는 제약이 존재한다.
    • 코드로 작성해보았을 때 아무 문제가 안 생기는데, 여기서 어떤 제약이 있다고 하는 걸까요?

PickOne 커스텀 유틸리티 타입 구현하기

🔍 내용 (168쪽)

  • 옵셔널 + undefined로 타입을 지정하면 사용자가 의도적으로 undefined 값을 넣지 않는 이상, 원치 않는 속성에 값을 넣었을 때 타입 에러가 발생할 것이다.
{ account: string; card?: undefined} | { account?: undefined; card: string}

Promise.all을 사용할 때 NonNullable 적용하기

🔍 내용 (172쪽)

  • Array<AdCampaign[] | null>로 추론된다. NonNullable을 사용해서 필터링하면 Array<AdCampaign[]>로 추론할 수 있게 된다.

무한한 키를 집합으로 가지는 Record

🔍 내용 (179쪽)

type Category = string;
const foodByCategory: Record<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "콩나물국밥" }],
일식: [{ name: "회", name: "텐동" }],
};

foodByCategory["양식"].map((food) => food.name); // 런타임에서 undefined가 되어 오류 반환
type PartialRecord<K extends string, T> = Partial<Record<K, T>>;

const foodByCategory: PartialRecord<Category, Food[]> = {
한식: [{ name: "제육덮밥" }, { name: "콩나물국밥" }],
일식: [{ name: "회", name: "텐동" }],
};

foodByCategory["양식"].map((food) => food.name); // Object is possibly `undefined`

· 약 13분
고현수

1. 클래스 컴포넌트 타입

interface Component<P = {}, S = {}, SS = any>
extends ComponentLifecycle<P, S, SS> {}
class Component<P, S> {
/* 생략 */
}
class PureComponent<P = {}, S = {}, SS = any> extends Component<P, S, SS> {}
  • P, S는 props와 상태를 의미
  • props와 state를 제네릭으로 받고 있다.
interface WorkerProps {
authrization: string;
}

class Worker extends React.Component<WorkerProps> {}

2. 함수 컴포넌트 타입

  • 리액트 v18에서 React.VFC가 삭제되었다.
  • React.FC에서 children이 사라졌다.
interface Workder {
authrization: string;
}
const Worker = ({ authrization }: Worker) => {};

3. Children props 타입 지정

type PropsWithChildren<P> = P & { children?: ReactNode | undefined };
  • ReactNode는 ReactElement외에도 boolean, number 등 여러 타입을 포함하고 있는 타입이다.
    • 더 구체적으로 타이핑 하는 용도로는 적합하지 않다.
    • 특정 문자열만 허용하고 싶다면 구체적으로 타입을 지정하면 된다.
//example 1
type FrontendWorkerProps = {
children: "엄문주" | "성은지" | "고현수" | "박윤국";
};
//example 2
type FrontendWorkerProps = {
children: string;
};
//example 3
type FrontendWorkerProps = {
children: ReactElement;
};

4. render 메서드와 함수 컴포넌트의 반환 타입 - React.ReactElement vs JSX.Element vs React.ReactNode

ReactElement

  • 함수 컴포넌트의 반환 타입이다.
interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string|JSXElementConstructor<any>{
type: T;
props: P;
key: Key | null;
}
  • React.createElement를 호출하는 형태의 구문으로 변환하면 React.createElement의 반환 타입은 ReactElement이다.
  • 가상 DOM의 엘리먼트는 ReactElement의 형태로 저장된다.
  • ReactElement타입은 리액트 컴포넌트를 객체 형태로 저장하기 위한 포멧이다.
  • JSX는 createElement 메서드를 호출하기 위한 문법이다. JSX는 리액트 엘리먼트를 생성하기 위한 문법이며 트랜스파일러는 JSX 문법을 createElement 메서드 호출문으로 변환하여 리액트 엘리먼트를 생성한다.
const element = React.createElement(
"h1",
{ className: "greeting" },
"Hello, world!"
);
  • 리액트는 리액트 엘리먼트 객체를 읽어서 DOM을 구성한다.
  • 리액트에는 여러 개의 createElement 오버라이딩 메서드가 존재하고, 이 매서드들이 반환하는 타입은 ReactElement 타입을 기반으로 한다.
  • 정리 : ReactElement 타입은 JSX의 createElement 메서드 호출로 생성된 리액트 엘리먼트를 나타내는 타입이다.

사용 예시

  • 추론 관점에서 더 유용하게 활용할 수 있는 방법은 JSX.Element 대신 ReactElement를 사용하는 것이다.
interface IconProps {
size: number;
}
interface Props {
// ReactElement의 props 타입으로 IconProps 타입 지정
icon: React.ReactElement<IconProps>;
}
const Item = ({ icon }: Props) => {
// icon prop으로 받은 컴포넌트의 props에 접근하면, props의 목록이 추론된다.
const iconSize = icon.props.size;
reutnr(<li>{icon}</li>);
};

JSX.Element

  • JSX.Element 타입은 리액트의 ReactElement를 확장하고 있는 타입이다.
  • 글로벌 네임스페이스에 정의되어 있어 외부 라이브러리에서 컴포넌트 타입을 재정의 할 수 있는 유연성을 제공한다.

[!NOTE] 글로벌 네임스페이스 식별자가 정의되는 전역적인 범위를 말한다. 어느곳에서든지 접근할 수 있다. 자바스크립트, 타입스크립트에서는 기본적으로 전역(글로벌) 스코프에서 선언된 변수나 함수 등은 글로벌 네임스페이스에 속한다.

declare global {
namespace JSX {
interface Element extends React.ReactElement<any, any> {}
}
}
  • props와 타입 필드에 대해 any 타입을 가지도록 확장하고 있다.

사용예시

  • props와 타입 필드가 any 타입인 리액트 엘리먼트를 나타낸다. 그래서 리액트 엘리먼트를 prop으로 전달받아 render props 패턴으로 컴포넌트를 구현할 때 유용하게 활용할 수 있다.
interface Props {
icon: JSX.Element;
}
const Item = ({ icon }: Props) => {
// prop으로 받은 컴포넌트의 props에 접근할 수 있다.
const iconSize = icon.props.size;

return <li>{icon}</li>;
};

// icon prop에는 JSX.Element 타입을 가진 요소만 할당할 수 있다.
const App = () => {
return <Item icon={<Icon size={14} />} />;
};
  • JSX 문법만 삽입할 수 있게 된다.
  • icon.props에 접근하여 prop으로 넘겨받은 컴포넌트의 상세한 데이터를 가져올 수 있다.

ReactNode

type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
type ReactFragment = {} | Iterable<ReactNode>; // ReactNode의 배열 형태

type ReactNode =
| ReactChild
| ReactFragment
| ReactPortal
| boolean
| null
| undefined;
  • ReactElement 외에도 boolean, string, number 등의 여러 타입을 포함하고 있다.
  • ReactChild 타입은 ReactElement | string | number로 정의되어 ReactElement보다는 좀 더 넓은 범위를 갖고 있다.
  • ReactNode는 리액트의 render 함수가 반환할 수 있는 모든 형태를 담고 있다.

사용 예시

  • 리액트의 Composition(합성) 모델을 활용하기 위해 prop으로 children을 많이 사용한다.
interface ExampleProps {
children: React.ReactNode;
}
  • 어떤 형태든 children props로 지정하고 싶다면 ReactNode를 사용하면 된다.

포함관계(결론)

  • ReactNode > ReactElement > JSX.Element

5. 리액트에서 기본 HTML 요소 타입 활용하기

DetailedHTMLProps와 ComponentWithoutRef

DetailedHTMLProps를 활용

type NativeButtonProps = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
type ButtonProps = {
onClick?: NativeButtonProps["onClick"];
};

ComponentWithoutRef

type NativeButtonType = React.ComponentPropsWithoutRef<"button">;
type ButtonProps = {
onClick?: NativeButtonType["onClick"];
};
  • 언제 사용할까?
    • 함수 컴포넌트의 경우 클래스 컴포넌트와 다르게 전달받은 ref가 Button 컴포넌트의 button 태그를 바라보지 않는다.
cosnt WrapperButton = () =>{
const buttonRef = useRef();
return (
<div>
<Button ref={buttonRef} />
</div>
)
}
  • 클래스 컴포넌트에서 ref 객체는 마운트된 컴포넌트의 인스턴스를 current 속성값으로 가지지만, 함수 컴포넌트에서는 생성된 인스턴스가 없기 때문에 ref에 기대한 값이 할당되지 않는다.
  • forwardRef는 이런 제약을 극복하게 해준다.
const Button = forwardRef((props, ref)=>{
return <button ref={ref} {...props}>버튼</button>
})

cosnt WrapperButton = () =>{
const buttonRef = useRef();
return (
<div>
<Button ref={buttonRef} />
</div>
)
}
  • forwardRef는 2개의 제네릭 인자를 받을 수 있다. 첫 번째는 ref에 대한 타입 정보, 두번째는 props에 대한 타입 정보다.
type NativeButtonType = React.ComponentPropsWithoutRef<"button">;

const Button = forwardRef<HTMLButtonElement, NativeButtonType>((props, ref) => {
return (
<button ref={ref} {...props}>
버튼
</button>
);
});
  • ComponentPropsWIthoutRef<"button"> 타입은 button 태그에 대한 HTML 속성을 모두 포함하지만, ref 속성은 제외된다.

8.2 타입스크립트로 리액트 컴포넌트 만들기

리액트 이벤트

  • 리액트는 가상 DOM을 다루면서 이벤트도 별도로 관리한다.
  • 리액트 이벤트는 브라우저의 고유한 이벤트와 완전히 동일하게 동작하지 않는다. 예를 들어 리액트 이벤트 핸들러는 이벤트 버블링 단계에서 호출된다.
  • Capture 단계에서 이벤트 호출을 하려면 onClickCapture와 같이 Capture를 붙여야한다.
  • 리액트는 브라우저 이벤트를 합성한 합성 이벤트를 제공한다.
type EventHandler<Event extends React.SyntheticEvent> = (
e: Event
) => void | null;
type ChangeEventHandler = EventHandler<ChangeEvent<HTMLSelectElement>>;

const eventHandler1: GlobalEventHandlers["onchange"] = (e) => {
e.target; // 일반 Event는 target이 없음.
};
const eventHandler2: ChangeEventHandler = (e) => {
e.target; // 리액트 이벤트(합성 이벤트)는 target이 있다.
};

currentTarget과 target의 차이

  • ChangeEventHandler -> target
  • ChangeEvent -> currentTarget

제네릭 컴포넌트 만들기

  • Select를 사용하는 입장에서 제한된 키(key)와 값(value)만을 가지도록 하려면 어떻게 해야할까? 함수 컴포넌트도 함수이기 때문에 제네릭을 사용한 컴포넌트를 만들어낼 수 있다.
interface SelectProps<OptionType extends Record<string, string>> {
options: OptionType;
selectedOption?: keyof OptionType;
onChange?: (selected?: keyof OptionType) => void;
}

const Select = <OptionType extends Record<string, string>>({
options,
selectedOption,
onChange
}: SelectProps<OptionType>) => {
const handleChange = (e) => {
const selected = Object.entries(options).find(
([_, value]) => value === e.target.value
)?.[0];
onChange?.(selected);
};

return (
<select
onChange={handleChange}
value={selectedOption && options[selectedOption]}
>
{Object.entries(options).map(([key, value]) => (
<option key={key} value={value}>
{value}
</option>
))}
</select>
);
};

const fruits = {
apple: "사과",
banana: "바나나",
blueberry: "블루베리"
};

type Fruit = keyof typeof fruits;

const FruitSelect = () => {
const [, changeFruit] = useState<Fruit | undefined>();
return (
// Type '"orange"' is not assignable to type '"apple" | "banana" | "blueberry" | undefined'.ts(2322)
<Select options={fruits} onChange={changeFruit} selectedOption="orange" />
);
};

공변성 반공변성

interface Props<T extends string> {
onChangeA?: (selected: T) => void;
onChangeB?(selected: T): void;
}

const Component = () => {
const changeToPineApple = (selectedApple: "apple") => {
console.log("this is pine" + selectedApple);
};
return (
<Select
//Error
// onChangeA={changeToPineApple}
//OK
onChangeB={changeToPineApple}
/>
);
};
  • 타입 A가 B의 서브타입일 때, T<A>T<B>의 서브타입이 된다면 공변성을 띄고 있다고 말한다.
  • 일반적으로 타입들은 공변성을 가지고 있어서 좁은 타입에서 넓은 타입으로 할당이 가능하다.
// 베이스 타입
interface User {
id: string;
}

// Member는 User의 서브 타입이다. User를 상속하기 떄문에
interface Member extends User {
// id를 가지고 있다.
nickName: string;
}

let users: Array<User> = [];
let members: Array<Member> = [];
// Member는 User에 대해 공변성을 띄고 있다.
users = members;
members = users; // Error nickName이 없다.
  • 제네릭 타입을 지닌 함수는 반공변성을 가진다. 즉 T<B>T<A>의 서브타입이 되어, 좁은 타입 T<A>의 함수를 넓은 타입 T<B>의 함수에 적용할 수 없다.
type PrintUserInfo<U extends User> = (user: U) => void;
let printUser: PrintUserInfo<User> = (user) => console.log(user.id);
let printMember: PrintUserInfo<Member> = (user) =>
console.log(user.id, user.nickName);

printMember = printUser;
printUser = printMember;
  • User가 Member보다 넓은 타입이다.
  • 좁은 타입 Member를 넓은 타입 User에 할당할 수 없다.

참고자료

번역 타입스크리트의 공변성과 반공변성

· 약 6분
박윤국

(props, state) => UI(JSX)

1. 상태 관리

1-1. 상태(state)

렌드링 결과에 영향을 주는 정보를 담은 순수 자바스크립트 객체

1-2. 상태를 잘 관리하기 위한 가이드

  • 시간이 지나도 변하지 않나요? 그러면 확실히 state가 아닙니다.
  • 부모로부터 props를 통해 전달됩니까? 그러면 확실히 state가 아닙니다.
  • 컴포넌트 안의 다른 state나 props를 가지고 계산 가능한가요? 그렇다면 절대로 state가 아닙니다!

그 외 남는 건 아마 state일 겁니다.

📚 React 공식문서 - thinking in react

내부에 존재하는 상태를 useEffect로 동기화하면 개발자가 추적하기 어려워 오류가 발생할 수 있다. useEffect로 동기화 하는 것은 피해야 한다.
📚 311p

1-3. useState vs useReducer

useState 대신 useReducer 사용을 권장하는 경우는 크게 2가지가 있따.

  • 다수의 하위 필드를 포함하고 있는 복잡한 상태 로직을 다룰 때
  • 다음 상태가 이전 상태에 의존적일 때

Advanced Search와 같이 쿼리를 상태로 저장해야한다고 해보자. 이런 쿼리들은 단순하지 않고 검색 날짜 범위, 키워드, 카드사 등 다양한 필드를 포함할 수 있다. 페이지네이션을 고려한다면 페이지, 사이즈 등의 필드도 추가될 수 있다.

type DateRangePreset = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth';

enum CardSet = {
VISA = 'VISA',
MASTER = 'MASTER',
AMEX = 'AMEX',
JCB = 'JCB',
UNIONPAY = 'UNIONPAY',
}

interface SearchFilter {
keyword: string;
dateRange: DateRangePreset;
cardSet: CardSet[];
// 이외 기타 필터링 옵션
}

interface State {
filter: SearchFilter;
page: number;
size: number;
}

이러한 데이터 구조를 useState로 다루면 상태를 업데이트할 때마다 잠재적인 오류 가능성이 높아진다. 이런 복잡한 상태 로직을 다룰 때는 useReducer를 사용하는 것이 좋다.
useReducer는 '무엇을 변경할지'와 '어떻게 변경할지'를 분리하여 dispatch를 통해 어떤 작업을 할지를 액션으로 넘기고 reducer 함수 내에서 상태를 업데이트하는 방식을 정의한다
📚 314p

// Action 정의
type Action =
| { payload: SearchFilter; type: "filter" }
| { payload: number; type: "navigate" }
| { payload: number; type: "resize" };

// Reducer 정의
const reducer: React.Reducer<State, Action> = (state, action) => {
switch (action.type) {
case "filter":
return { filter: action.payload, page: 0, size: state.size };
case "navigate":
return { ...state, page: action.payload };
case "resize":
return { ...state, size: action.payload };
default:
return state;
}
};

// useReducer 사용
const [state, dispatch] = useReducer(reducer, initialState);

// dispatch 사용
dispatch({
type: "filter",
payload: { keyword: "react", dateRange: "today", cardSet: ["VISA"] },
});
dispatch({ type: "navigate", payload: 1 });
dispatch({ type: "resize", payload: 10 });

이외에도 boolean 상태를 토글하는 액션만 사용하는 경우 useState 대신 useReducer를 사용하곤 한다.

const [fold, setFold] = useReducer((fold) => !fold, false);

1-4. Context API

Context API를 이용하여 전역 상태를 관리하는 것은 대규모 애플리케이션이나 성능이 중요한 애플리케이션에서 권장되진 않는다.

  • 컨텍스트 프로바이더의 props로 주입된 값이나 참조가 변경될 때마다 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링

컨텍스트를 생성할 때 관심사를 잘 분리해서 구성하면 리렌더링을 최소화할 수 있지만, 애플리케이션이 커지고 전역 상태가 많아질수록 불필요한 리렌더링과 상태의 복잡도가 증가한다.

Bad Case 예시 하나의 컨텍스트에 여러 관심사 포함:

const AppStateContext = React.createContext();

function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [products, setProducts] = useState([]);

return (
<AppStateContext.Provider
value={{ user, setUser, theme, setTheme, products, setProducts }}
>
{/* Application Components */}
</AppStateContext.Provider>
);
}

이 경우, 사용자 정보, 테마, 제품 데이터 등 서로 다른 관심사의 상태들이 하나의 컨텍스트에 포함되어 있습니다. 따라서 테마를 변경할 때 사용자 정보와 제품 정보를 포함한 모든 컴포넌트가 리렌더링 될 수 있습니다.

const UserContext = React.createContext();
const ThemeContext = React.createContext();
const ProductContext = React.createContext();

function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [products, setProducts] = useState([]);

return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<ProductContext.Provider value={{ products, setProducts }}>
{/* Application Components */}
</ProductContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}