기초 및 개념 이해

React와 TypeScript

타입 시스템 심화 및 패턴

성능 및 효율적인 코드 작성

스타일 가이드 및 실전 활용

Node.js 및 빌드 관련

함수형 프로그래밍과 TypeScript

마이그레이션 사례


Footnotes

  1. Chrome DevTools 및 Puppeteer를 TypeScript로 마이그레이션한 경험에 대한 설명 ↩

  2. 대규모 JavaScript 코드베이스를 TypeScript로 자동 마이그레이션하는 도구인 “ts-migrate”를 소개하는 Airbnb 엔지니어링 팀의 블로그 게시물 ↩

  3. Flow에서 TypeScript로 마이그레이션한 경험을 설명하고 오픈소스 flow-to-typescript-codemod 도구에 대한 링크를 포함하는 Stripe 엔지니어링 팀의 블로그 게시물 ↩

  4. Etsy 엔지니어링 팀이 대규모 웹 애플리케이션에서 TypeScript를 채택한 방법, 직면한 문제 및 얻은 이점을 설명 ↩

#13
interface MathFn {
  (a: number, b: number): number
}
const sum: MathFn = (a, b) => a + b

Footnotes

  1. function declarations, function expressions, arrow functions, methods등 TypeScript에서 함수를 선언하는 다양한 방법들. ↩

  2. TypeScript에서 함수를 입력하는 방법에 대한 종합적인 가이드 ↩

#15
const { Parser } = require('acorn')
const JSXParser = Parser.extend(require('acorn-jsx')())

const isReactComponent = Boolean(
  JSON.stringify(
    JSXParser.parse(fileContent, {
      sourceType: 'module',
      ecmaVersion: 'latest',
    })
  ).includes('JSXIdentifier')
)

#255

오버로드는 ReturnType·infer 로 하나를 고를 수 없다 — 마지막 시그니처만 본다. 시그니처를 알면 할당 가능한지로 고른다.

T extends R 는 “R 처럼 불러도 T 가 받아내나?“를 묻는다. 오버로드된 T 는 시그니처 중 하나만 받아내면 된다. 시그니처 하나가 받아내는 조건1은 셋이다.

  1. R 이 넘기는 인자보다 더 요구하지 않는다 — 남는 인자는 버리면 된다
  2. 받는 인자의 타입이 맞는다
  3. 돌려주는 값이 R 의 반환 자리에 들어간다
type Year = {
  year(): number
  year(u: string): string
}

// 추출이 아니라 할당 검사: 받아내는 시그니처가 있으면 R 을 그대로 돌려준다
type ReturnTo<T, R> = T extends R ? R : never

type GetYear = ReturnTo<Year['year'], () => number> // () => number
type SetYear = ReturnTo<Year['year'], (u: string) => string> // (u: string) => string
type Loose = ReturnTo<Year['year'], (u: number) => number> // 통과 — year() 가 u 를 버리고 받아낸다 (1)

type A = ReturnType<Year['year']> // string — 마지막만. 순서를 바꾸면 number
type B = Year['year'] extends () => infer R ? R : never // never

infer 는 묻지 않고 꺼낸다 — 마지막 시그니처에서 R 을 정하고, 인자 목록으로 오버로드를 고르는 건 불가능하다2. B 는 R = string 을 꺼낸 뒤 () => string 을 받아내는 시그니처가 없어서 never 다.

직접 정의하는 쪽이면 오버로드 대신 제네릭 하나로 — year<U extends [] | [string]>(...a: U): U extends [] ? number : string 이면 ReturnType<typeof y.year<[]>> 로 꺼낸다.


Footnotes

  1. Type Compatibility — Comparing two functions. 오버로드: “each overload in the target type must be matched by a compatible signature on the source type”. 인자 버리기: “ignoring extra function parameters is actually quite common in JavaScript”. ↩

  2. Conditional Types — Inferring Within Conditional Types: “inferences are made from the last signature … It is not possible to perform overload resolution based on a list of argument types.” ↩

#345

템플릿 리터럴 타입으로 prop 이름을 만든다 — 매핑 타입이 union 멤버를 하나씩 K 로 돌고, as 가 `render${K}` 로 이름을 바꾼다.1

import * as React from 'react'

type RenderPropNames = 'Title' | 'Content' | 'Actions'

type RenderProps = {
  [K in RenderPropNames as `render${K}`]: () => React.ReactNode
}

type Props = RenderProps

/**
 * @example
 *
 * ```tsx
 * <DialogComponent
 *   renderTitle={() => <h2>Title</h2>}
 *   renderContent={() => <p>Content</p>}
 *   renderActions={() => (
 *     <div>
 *       <button onClick={handleClose}>Close</button>
 *       <button onClick={handleSubmit}>Submit</button>
 *     </div>
 *   )}
 * />
 * ```
 */
function Dialog({
  renderTitle,
  renderContent,
  renderActions,
}: Props) {
  return (
    <div data-scope="root">
      <div data-part="content">
        {renderTitle()}
        {renderContent()}
      </div>
      <div data-part="actions">{renderActions()}</div>
    </div>
  )
}

Footnotes

  1. TypeScript Handbook — Mapped Types, Key Remapping via as (TS 4.1+): “You can leverage features like template literal types to create new property names from prior ones”. K 가 멤버 하나일 때 템플릿 리터럴은 “produces a new string literal type by concatenating the contents”. ↩

#350
type JsonPrimitive = string | number | boolean | null
type JsonObject = { [Key in string]: JsonValue } & {
  [Key in string]?: JsonValue | undefined
}
type JsonArray = JsonValue[] | readonly JsonValue[]
type JsonValue = JsonPrimitive | JsonObject | JsonArray

예전에 JSON 타입 정의가 필요해서 찾아봤던 내용

  • JsonObject: 문자열 키와 JsonValue 타입의 값을 가진 JSON 객체를 정의
  • JsonArray: JsonValue 타입의 요소를 포함하는 JSON 배열을 정의
  • JsonPrimitive: 문자열, 숫자, 불린, 또는 null과 같은 유효한 JSON 기본 값을 정의
  • JsonValue: 유효한 JSON 값을 나타내며, JsonPrimitive, JsonObject, 또는 JsonArray로 구성

#376

static create 패턴 - 생성자 대신 정적 메서드로 객체 생성

언제 사용?

  • 생성 로직이 복잡하거나 유효성 검사 필요
  • 생성 실패 시 null/Result 반환 (생성자는 항상 인스턴스 반환)
  • 팩토리 패턴, 싱글톤
class User {
  private constructor(private readonly name: string) {}

  // 유효성 검사 + 실패 시 null 반환
  static create(name: string): User | null {
    if (!name || name.length < 3) return null
    return new User(name)
  }
}

// 팩토리 패턴
class Shape {
  static create(type: 'circle' | 'rect', size: number): Shape {
    return type === 'circle' ? new Circle(size) : new Rectangle(size)
  }
}

// 싱글톤
class Config {
  private static instance: Config
  private constructor() {}
  static create() {
    return Config.instance ??= new Config()
  }
}

단순 초기화만 필요하면 일반 생성자가 더 직관적. 복잡한 생성 로직에만 사용.

#381

Figma의 권한 관리 DSL - JSON 직렬화 가능한 DSL로 정책 표현, TypeScript 기반 평가 엔진 구현.

기존 문제: 불필요한 복잡성, 계층적 권한 비효율, DB 부하, 여러 진실 소스

type ExpressionDef = BinaryExpressionDef | OrExpressionDef | AndExpressionDef

// 바이너리 표현식: [필드, 연산자, 값]
const binaryExpression = ['file.id', '<>', null] satisfies ExpressionDef

// AND/OR 조합
const andExpression = {
  and: [
    ['file.id', '<>', null],
    ['team.permission', '=', 'open'],
  ],
} satisfies ExpressionDef
#382

verbatimModuleSyntax 를 켜면 type 없는 import 는 출력에 그대로 남는다 — 타입만 가져올 땐 import type 으로 표시해야 한다(안 하면 TS1484).1

import type { A } from 'a'

Footnotes

  1. 아래 TSConfig Reference 의 verbatimModuleSyntax: “any imports or exports without a type modifier are left around. Anything that uses the type modifier is dropped entirely.” ↩

#462

React 제네릭 컴포넌트 패턴 - <T extends Record<string, any>>로 row/item 타입 추론

import * as React from 'react'

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
}

const Table = <TRow extends Record<string, any>>({
  rows,
  renderRow,
}: Props<TRow>) => {
  return (
    <table>
      <tbody>{rows.map((row, index) => renderRow(row, index))}</tbody>
    </table>
  )
}

function App() {
  return (
    <Table
      rows={[{ name: 'lee' }]}
      renderRow={(row, index) => (
        <tr key={index}>
          <td>{row.name}</td>
        </tr>
      )}
    />
  )
}

row.name 이 되는 이유 — TS 는 Props<TRow> 와 넘긴 값을 나란히 놓고 TRow 를 줍는다.1

Props<TRow> 와 넘긴 props 를 칸마다 나란히 놓는다. rows: TRow[] 자리에는 { name: string }[] 이 와서 배열 안의 맨몸 TRow 자리에서 { name: string } 을 후보로 줍는다. renderRow 의 row 는 타입을 안 적었으니 주울 게 없다. extends Record<string, any> 는 넘긴 값이 아니라 후보가 아니다. 후보에서 고른 TRow 를 제약에 맞춰 보고, 안 맞으면 TRow 를 제약으로 바꿔 끼운 채 검사해 에러를 낸다. 후보로 TRow = { name: string } 이 정해지면 그 타입이 거꾸로 renderRow 의 row 로 흘러가서 row.name 을 쓸 수 있다.

후보가 여럿이면 자리에 따라 고른다 — 화살표 함수를 직접 쓰면 매개변수를 다 적었는지에 따라 자리가 바뀐다.2

rows={dogs} 는 두 경우 모두 내주는 쪽(공변) 후보 Dog 를 낸다. renderRow 의 매개변수를 다 적으면(row: Animal, index: number) 함수 타입끼리 비교해 Animal 이 받는 쪽(반변) 후보가 되고, Dog 가 Animal 에 들어가니 내주는 쪽이 이겨 TRow = Dog. 하나라도 덜 적으면((row: Animal) 또는 index 에 타입 없음) 적힌 타입만 읽어 Animal 을 내주는 쪽 후보로 넣고, Dog 와 Animal 의 공통 상위 타입을 골라 TRow = Animal. 화살표 함수 리터럴일 때 얘기다 — 이미 만든 함수를 넘기면 덜 적어도 Dog, function 표현식은 다 적어도 Animal.

TRow 를 두 번째로 쓰는 prop 은 NoInfer 로 줍기에서 뺀다 (TS 5.4+).

type Props<TRow> = {
  rows: TRow[]
  renderRow: (row: TRow, index: number) => React.ReactNode
  selectedRow?: NoInfer<TRow>
}

rows={[{ name: ‘lee’ }]} selectedRow={{ name: ‘kim’, age: 1 }} 를 넘긴다. NoInfer 가 없으면 두 자리 모두에서 후보를 줍고, 둘 다 객체 리터럴이라 union 으로 합쳐져 TRow 가 넓어지고 에러가 나지 않는다. NoInfer 가 있으면 selectedRow 는 후보로 줍지 않아 TRow = { name: string } 이 되고, selectedRow 를 그 타입으로 검사하다 age 에서 에러가 난다.

extends Record<string, any> 는 원시값만 막는다.

Record<string, any> 는 { [key: string]: any } 로 펼쳐진다. 값이 any 인 인덱스 시그니처는 원시값만 빼고 다 받아서 type 별칭 객체·interface·배열·함수가 통과하고 원시값만 막힌다. Record<string, unknown> 은 암묵적 인덱스 시그니처가 있는 type 별칭 객체만 통과한다 — interface 는 선언 병합으로 속성이 늘 수 있어 암묵적 인덱스 시그니처를 받지 못하고, 배열·함수도 막힌다. object 는 원시값만 막는다.

import * as React from 'react'
import { UseComboboxProps, useCombobox } from 'downshift'

function Combobox<T extends Record<string, any>>(
  props: UseComboboxProps<T> & {
    renderItem: (item: T) => React.ReactNode
  }
) {
  const combobox = useCombobox(props)

  return (
    <div>
      <input
        placeholder="구성원을 검색해주세요."
        {...combobox.getInputProps()}
      />
      <div {...combobox.getMenuProps()}>
        {combobox.isOpen &&
          props.items.map((item, index) => (
            <div key={item.id} {...combobox.getItemProps({ item, index })}>
              {props.renderItem(item)}
            </div>
          ))}
      </div>
    </div>
  )
}

function App() {
  return (
    <Combobox
      items={[{ id: 1, name: 'eunsoo' }]}
      itemToString={(item) => `${item?.id}`}
      renderItem={(item) => {
        return <div>{item.name}</div>
      }}
      onInputValueChange={({ inputValue }) => {
        console.log(inputValue)
      }}
    />
  )
}

Footnotes

  1. norswap — How TypeScript infers type variables: “Every time the walk reaches a bare type parameter on the target side, record the corresponding source type as a candidate.” ↩

  2. TypeScript v5.9.3 checker.ts — 매개변수를 덜 적거나(L39568) 타입을 안 적은 게 있는(L39548) 함수 리터럴은 적힌 타입만 그대로 후보로 넣고, 같은 줄 후보끼리는 “infer a common supertype”. 두 줄이 다 있으면 “we prefer the co-variant inference if … it is assignable to some contra-variant inference”. ↩

#480

교차 타입을 한 객체로 펼쳐 보여준다 — 매핑 타입이 속성을 다시 모으고, & {} 가 있어야 별칭 이름(Prettify<…>) 대신 펼친 모양({ a: 1; b: 2 })이 뜬다.1

import * as React from 'react'

type Prettify<T> = {
  [K in keyof T]: T[K]
} & {}

type T = React.ComponentProps<'div'>

type P = Prettify<T>

Footnotes

  1. & {} 를 빼면 에러 메시지에 별칭 이름이 그대로 나온다(tsc 5.9.3). & unknown 도 같다. 왜 그런지는 공식 설명이 없다 — Total TypeScript — The Prettify Helper: “It’s not clear why using a mapped type and intersecting it with {} actually works. It’s a quirk of the TypeScript compiler.” ↩

#481

리터럴 타입 공통 속성(variant)으로 union 을 가르면 분기 안에서 좁혀진다.1 no-title 에 title 을 붙이면 막히는 것도 같은 축이다 — 초과 속성 검사가 variant 로 고른 멤버 하나에 대고 이뤄진다.2

import * as React from 'react'
import { IsEqual } from 'type-plus'

type ModalProps =
  | {
      variant: 'no-title'
    }
  | {
      variant: 'title'
      title: string
    }

export const Modal = (props: ModalProps) => {
  if (props.variant === 'no-title') {
    return <div>No title</div>
  } else {
    type Test = IsEqual<
      typeof props,
      {
        variant: 'title'
        title: string
      }
    >

    return <div>Title: {props.title}</div>
  }
}

export const Test = () => {
  return (
    <div>
      <Modal variant="title" title="Hello" />
      <Modal variant="no-title" />

      {/* @ts-expect-error */}
      <Modal />
      <Modal
        variant="no-title"
        // @ts-expect-error
        title="Oh dear"
      />
    </div>
  )
}

Footnotes

  1. TypeScript Handbook — Narrowing, Discriminated unions: “When every type in a union contains a common property with literal types, TypeScript considers that to be a discriminated union, and can narrow out the members of the union.” ↩

  2. 판별 속성이 없는 union({ a: number } | { b: string })은 { a: 1, b: 'x' } 를 받는다 — 3.5 릴리스 노트도 “partial overlap is still permitted” 라 한다. 막히는 건 검사 대상을 판별 속성으로 먼저 좁히기 때문이다: TypeScript v5.9.3 checker.ts — hasExcessProperties reducedTarget = findMatchingDiscriminantType(source, target, …). ↩

#482

T extends string 이 있어야 넘긴 값을 리터럴 그대로("") 줍는다1 — 그래서 '' 를 넣으면 Exclude<'', ''> 가 never 가 돼 막힌다.

import * as React from 'react'

export type NonEmptyString<T> = Exclude<T, ''>

type Props<T> = {
  src: NonEmptyString<T>
}

function Image<T extends string>({ src }: Props<T>) { // 제약을 빼면 T = string 으로 넓어져 '' 도 통과
  return <img src={src} alt="" />
}

function App() {
  return (
    <>
      {/* @ts-expect-error */}
      <Image src="" />

      <Image src="ab" />
    </>
  )
}

다만 string 변수를 넘기면 T = string 이라 Exclude<string, ''> 도 string — 값이 '' 여도 통과한다. 인자 타입이 리터럴 '' 일 때만 막는 가드다(const e: '' = '' 를 넘겨도 막힌다).


Footnotes

  1. 객체 속성에 넣은 리터럴은 보통 string 으로 넓어지는데(<T>(x: { a: T }) 에 { a: 'ab' } → string), 들어갈 자리의 타입(여기선 Exclude<T, ''>)이 string 제약을 가지면 리터럴로 남긴다. TypeScript v5.9.3 checker.ts — isLiteralOfContextualType: “If the contextual type is a type variable constrained to a primitive type, consider this a literal context for literals of that primitive type. For example, given a type parameter ‘T extends string’, infer string literal types for T.” ↩

#483

TypeScript 중첩 객체 타입 부분 수정 — 바깥에서 K 를 Omit 으로 빼고, 같은 키를 매핑 타입으로 다시 붙이면서 안쪽만 바꾼다.1

interface O {
  actions: { a: string; b: number }
}

// 중첩 속성 Optional로 변경
type MakeNestedOptional<T, K extends keyof T, OK extends keyof T[K]> = Omit<
  T,
  K
> & {
  [P in K]: Omit<T[K], OK> & Partial<Pick<T[K], OK>>
}
type Result = MakeNestedOptional<O, 'actions', 'b'> // { actions: { a: string; b?: number } }

// 중첩 속성 타입 오버라이드
type OverrideNested<T, K extends keyof T, Override> = Omit<T, K> & {
  [P in K]: Omit<T[K], keyof Override> & Override
}
type Result2 = OverrideNested<O, 'actions', { b: boolean }> // { actions: { a: string; b: boolean } }

한두 군데만 쓸 거면 그냥 손으로 타입 작성이 더 명확함. 유틸리티 타입은 반복 사용할 때만 가치.

// 라이브러리 객체 변이 주의
// ❌ info.actions.onDownload = undefined
// ✅ const modifiedInfo = { ...info, actions: { ...info.actions, onDownload: undefined } }

Footnotes

  1. Utility Types — Omit<Type, Keys> | TypeScript: “Constructs a type by picking all properties from Type and then removing Keys (string literal or union of string literals). The opposite of Pick.” ↩

#499

TypeScript 제네릭 기본값 - <T extends Type = DefaultValue>. 기본값은 제약을 만족해야 하고, 생략한 인자는 기본값으로 정해진다.1

type SelectProps<T extends 'single' | 'multiple' = 'multiple'> = {
  mode: T
  value: T extends 'single' ? string : string[]
}

const a: SelectProps = { mode: 'multiple', value: ['a', 'b'] } // 기본값 사용
const b: SelectProps<'single'> = { mode: 'single', value: 'a' } // 명시적 지정

// 여러 매개변수에 각각 기본값
type ApiResponse<
  TData = any,
  TStatus extends 'loading' | 'success' | 'error' = 'loading'
> = {
  data: TStatus extends 'success' ? TData : null
  status: TStatus
}

가장 자주 사용되는 케이스를 기본값으로 설정하면 제네릭을 항상 명시하는 번거로움 줄어듦.


Footnotes

  1. TypeScript 2.3 — Generic parameter defaults: “Default types for a type parameter must satisfy the constraint for the type parameter, if it exists.” / “Unspecified type parameters will resolve to their default types.” ↩

#516

TypeScript Discriminated Union 문서화 딜레마

// 개발: 타입 안전, IDE 자동완성, 명확한 의도
type Button =
  | { type: 'submit'; color: string }
  | { type: 'reset'; text: string }
<!-- 문서화: 조건부 속성 설명 어려움, 테이블 복잡, 예시 다수 필요 -->

| 속성  | 타입                | 조건                    |
| ----- | ------------------- | ----------------------- |
| type  | 'submit' \| 'reset' | 필수                    |
| color | string              | type='submit'일 때 필수 |
| text  | string              | type='reset'일 때 필수  |

  • 자세한 타입 정의는 TypeScript 정의 파일 참고
  • typedoc, api-extractor로 자동 생성
#524

간단한 Redux 스타일 Store 구현

type Reducer<S, A> = (state: S, action: A) => S
type Listener<S> = (state: S) => void

interface Store<S, A> {
  getState: () => S
  subscribe: (listener: Listener<S>) => () => void
  dispatch: (action: A) => A
}

function createStore<S, A>(
  reducer: Reducer<S, A>,
  preloadedState: S
): Store<S, A> {
  let currentState = preloadedState
  let listeners: Listener<S>[] = []

  return {
    getState: () => currentState,
    subscribe: (listener) => {
      listener(currentState)
      listeners.push(listener)
      return () => {
        listeners = listeners.filter((l) => l !== listener)
      }
    },
    dispatch: (action) => {
      currentState = reducer(currentState, action)
      listeners.forEach((l) => l(currentState))
      return action
    },
  }
}
#529

Maybe Monad로 null 체크 체이닝

type Maybe<T> = T | null

const Maybe = {
  of: <T>(value: T): Maybe<T> => (value != null ? value : null),
  map: <T, U>(m: Maybe<T>, fn: (v: T) => U): Maybe<U> =>
    m != null ? Maybe.of(fn(m)) : null,
}

// 사용 예: DOM 요소 찾아서 스크롤
Maybe.of(scrollElement.current)
  .map((root) => root.querySelector(`#${id}`))
  .map((target) => target.getClientRects()[0])
  .map((rect) => {
    root.scrollLeft = rect.left
  })

Generator로 early return 패턴도 가능

function* handleScroll(id) {
  const root = scrollElement.current
  if (!root) return
  const target = root.querySelector(`#${id}`)
  if (!target) return
  yield target.getClientRects()[0]?.left ?? 0
}
#533

TypeScript Maybe Monad 구현

type Maybe<T> = Just<T> | Nothing

class Just<T> {
  constructor(public value: T) {}
  bind<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return fn(this.value)
  }
}

class Nothing {
  bind<U>(fn: (value: any) => Maybe<U>): Maybe<U> {
    return this
  }
}

const nothing = new Nothing()

function safeDivide(x: number, y: number): Maybe<number> {
  return y === 0 ? nothing : new Just(x / y)
}

new Just(10).bind((x) => safeDivide(x, 2)) // Just { value: 5 }
new Just(10).bind((x) => safeDivide(x, 0)) // Nothing {}
#535

isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.

useGenerator 의 상태는 useReducer 하나다. idle 에서 start 로 running 이 되고(에러도 함께 비운다), yield 는 status 를 두고 value 만 바꾸며, generator 가 끝나면 done, 본문 어디서든 throw 하면 catch 에서 error 로 errored 다. cancel 은 abort 하고 idle 로 되돌린다 — 안 하면 running 에 멈춘다. unmount 되면 cleanup 이 abort 하고, 기다리던 await wait 가 reject 돼 throw 가 나지만 aborted 라 에러로 치지 않는다. 같은 훅을 쓰는 쪽이 재진입 정책을 고른다: LoadingButton 은 running 동안 disabled 로 다시 못 누르게 하고 JSX 를 yield 하면 그대로 라벨이 된다. 검색은 run(q) 마다 직전 실행을 abort 하고 다시 시작해, a 다음 ab 를 치면 a 의 remote 결과가 끼어들지 않는다.

import { useEffect, useReducer, useRef } from 'react'

type Status = 'idle' | 'running' | 'errored' | 'done'

type State<T> = { status: Status; value: T; error: unknown }

type Action<T> =
  | { type: 'start' }
  | { type: 'yield'; value: T }
  | { type: 'done' }
  | { type: 'error'; error: unknown }
  | { type: 'cancel' }

function reducer<T>(state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case 'start':
      return { ...state, status: 'running', error: null } // 상태와 에러 초기화가 한 번에 — 따로 set 하다 어긋날 일이 없다
    case 'yield':
      return { ...state, value: action.value }
    case 'done':
      return { ...state, status: 'done' }
    case 'error':
      return { ...state, status: 'errored', error: action.error }
    case 'cancel':
      return { ...state, status: 'idle' } // 없으면 'running' 에 멈춰 있다
  }
}

function useGenerator<T, A extends unknown[]>(
  fn: (signal: AbortSignal, ...args: A) => AsyncGenerator<T>, // signal 은 훅이 첫 인자로 끼워 넣는다
  initial: T,
) {
  const [state, dispatch] = useReducer(reducer<T>, {
    status: 'idle',
    value: initial,
    error: null,
  })
  const ctrlRef = useRef<AbortController | null>(null)

  useEffect(() => () => ctrlRef.current?.abort(), []) // unmount 되면 진행 중인 흐름을 끊는다

  const run = async (...args: A) => {
    // 매 렌더 새로 만들어져 항상 최신 fn 을 읽는다
    ctrlRef.current?.abort() // 재진입 — 직전 실행을 끊고 다시 시작
    const ctrl = new AbortController()
    ctrlRef.current = ctrl
    dispatch({ type: 'start' })
    try {
      for await (const value of fn(ctrl.signal, ...args)) {
        if (ctrl.signal.aborted) return
        dispatch({ type: 'yield', value }) // yield 마다 리렌더
      }
      dispatch({ type: 'done' })
    } catch (error) {
      if (ctrl.signal.aborted) return // abort 로 생긴 throw 는 에러가 아니다
      dispatch({ type: 'error', error })
    }
  }

  const cancel = () => {
    ctrlRef.current?.abort()
    dispatch({ type: 'cancel' })
  }

  return { ...state, run, cancel }
}

검색은 같은 훅에서 재진입이 재시작이 된다 — 새 입력이 들어오면 이전 tier 가 끊긴다.

async function* searchTiers(signal: AbortSignal, q: string): AsyncGenerator<string[]> {
  await wait(100, signal); yield [`${q}:local`]
  await wait(300, signal); yield [`${q}:local`, `${q}:remote`]
}

const { value, run } = useGenerator(searchTiers, [])
<input onChange={(e) => run(e.target.value)} />

0ms 에 a 를 치면 run(a) 가 시작해 100ms 대기 뒤 a:local 을 yield 하고, 300ms 더 기다려 400ms 에 a:remote 를 낼 참이다. 그런데 150ms 에 ab 를 치면 run(ab) 가 직전 실행을 abort 하고, a 가 기다리던 wait 가 reject 돼 그 자리에서 멈춘다 — a:remote 는 오지 않는다. run(ab) 는 250ms 에 ab:local, 550ms 에 ab:local,ab:remote 를 내고 done 이 된다. 화면에는 a:local → ab:local → ab:local,ab:remote 순서로만 보인다. tier 경계(await wait)가 그대로 취소 지점이라 debounce 가 필요 없다.

언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 loading · progressive search(tier 경계 = 취소 지점, debounce 불필요) · optimistic mutation(optimistic | reconciled | rolledBack 전이를 콜백 넷 대신 한 함수로).

확인 — 원본 데모 대비 채운 셋: ERRORED 를 선언만 하고 안 쓰던 것(→ catch), unmount 시 취소(→ AbortSignal + cleanup), 타입(→ 제네릭 훅). 검색창에 a 다음 바로 ab 를 치면 a:remote 가 끼어들지 않고 ab:local → ab:local,ab:remote 로만 바뀌면 된 것이다.


#540

WeakMap의 키를 “입력 배열의 참조 자체”로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.

같은 배열을 이름 같은 키로 반복 조회해서 O(n) 스캔이 계속 도는데, 그 배열이 언제 바뀌는지 추적하기는 싫을 때 쓴다.

// 키가 weak — items 가 어디서도 안 잡히면 entry 도 GC. 일반 Map 이면 영구히 붙들려 누수
const indexCache = new WeakMap<Item[], Map<string, string>>()

// name → id 역인덱스를 1회 빌드 (O(n))
const buildIndex = (items: Item[]) =>
  new Map(items.map((it) => [it.name, it.id]))

function lookup(items: Item[], name: string): string | null {
  // 키는 내용이 아니라 참조(===)로 비교된다. 불변 업데이트면 "데이터가 바뀜" = "참조가 바뀜"이라
  // 이 조회가 곧 무효화 검사다 — 그래서 delete·clear 가 어디에도 없다
  let index = indexCache.get(items)
  if (!index) {
    // 새 참조(refetch 등) → 자동 miss. 옛 index 는 옛 items 와 함께 GC
    index = buildIndex(items)
    indexCache.set(items, index)
  }
  return index.get(name) ?? null // 여기부터 O(1) — 호출마다 돌던 find 의 O(n) 스캔이 사라진 자리
}

흩어진 호출 지점이 모두 lookup 하나를 거쳐 배열 참조로 캐시를 찾는다. 같은 참조면 인덱스를 재사용하고, refetch 로 새 참조가 오면 한 번 다시 빌드하며 옛 entry 는 GC 된다. push 처럼 제자리에서 바꾸면 참조가 같아 옛 인덱스가 나온다. 매번 새 배열을 넘기면 항상 miss 다.

같은 배열 참조로 두 번 불러 buildIndex가 한 번만 돌면 된 것이다.

단 호출 측 items 참조가 stable해야 작동한다. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK) 반대로 push·splice 로 제자리에서 바꾸면 참조가 같아 옛 인덱스가 그대로 나온다 — 이쪽은 느린 게 아니라 틀린 값이다.

참고

dayjs 날짜 표기 카탈로그

1. 문제 — 날짜 표기를 한곳으로 모으고 싶다

화면마다 dayjs().format('…')이 흩어져 같은 날짜가 다르게 찍힌다. 날짜 표기를 받는 인자를 타입으로 닫아 허용된 표기만 쓰게 하고 싶다.

2. 첫 발상 — 매핑 객체

표기 이름과 포맷 문자열을 짝짓는 객체({ DATE: 'YYYY-MM-DD', … })부터 떠올랐다. i18n 습관이다 — i18n은 이름표(인사)와 내용(Hello)이 달라서 짝지어 주는 객체가 필수였다.

그런데 dayjs는 포맷 문자열이 곧 키이자 값이다.1 이름을 따로 붙이면 같은 정보를 두 번 들고 있게 된다. 매핑 객체는 필요 없다.

3. 해결 — 배열 하나에서 파생

허용 표기를 as const 배열 하나에 두고, 타입과 진입점을 거기서 파생한다.

import dayjs from 'dayjs'

export const DATE_FORMATS = [
  'YYYY-MM-DD',
  'YYYY-MM-DDTHH:mm:ss',
  'HH:mm',
  'YYYY년 M월',
  'YY년 M월 D일',
  'YYYY.MM.DD',
  'YY.MM.DD',
  'YYYY.MM.DD(ddd)',
  'YYYY.MM.DD HH:mm',
] as const

export type DateFormat = (typeof DATE_FORMATS)[number]

export const formatDate = (
  date: dayjs.ConfigType,
  format: DateFormat,
): string => dayjs(date).format(format)
  • DATE_FORMATS — 허용 표기의 단일 출처
  • DateFormat — 배열에서 파생된 닫힌 union. 따로 손댈 곳이 없다
  • formatDate — 유일한 진입점

4. 새 표기가 필요해지면

  1. 여러 곳에서 통일이 필요한 표기인지 먼저 본다. 일회성('M.D' 등)이면 raw dayjs().format()으로 둔다 — 이 기준이 없으면 배열이 잡동사니 enum이 된다.
  2. DATE_FORMATS에 추가한다. 그게 전부다.
  3. ddd가 들어 있으면 진입점에 dayjs.locale('ko')가 있는지 본다.2

5. 남은 구멍

  • raw 우회는 타입이 못 막는다. 카탈로그에 있는 표기를 formatDate 없이 raw로 다시 써도 오류가 없다. no-raw-date-format(no-restricted-syntax)이 필요한데 아직 안 썼다. 그 규칙 전까지 단일 출처는 관행일 뿐이다.
  • ddd는 조용히 샌다. dayjs.locale('ko') 없이 쓰면 Mon·Tue처럼 영어로 나오고, 예외도 타입 오류도 없다. YYYY.MM.DD(ddd)만 런타임 전제를 가진 항목이다.

Footnotes

  1. dayjs — Format ↩

  2. dayjs — Loading locale ↩

#576