type Task = (callback: (result: string) => void) => void

type TasksCallback = (results: string[]) => void

class TaskRunner {
  protected tasks: Task[] = []

  protected results: Array<string> = []

  addTask(task: Task) {
    this.tasks.push(task)
  }
}

class ParallelTaskRunner extends TaskRunner {
  run(callback: TasksCallback) {
    const totalTasks = this.tasks.length

    this.tasks.forEach((task) => {
      task((result) => {
        this.results.push(result)

        if (this.results.length === totalTasks) {
          callback([...this.results])
        }
      })
    })
  }
}

class SerialTaskRunner extends TaskRunner {
  index = 0

  run(callback: TasksCallback) {
    const executeTask = () => {
      if (this.index >= this.tasks.length) {
        callback([...this.results])
        return
      }

      this.tasks[this.index]((result) => {
        this.results.push(result)

        this.index++

        executeTask()
      })
    }

    executeTask()
  }
}

Footnotes

  1. concurrently ↩

#485

Redux 스타일 Store 직접 구현 — state 는 클로저에 가두고, 바꾸는 길은 dispatch → reducer 하나뿐이다.1 Redux 와 달리 이 subscribe 는 등록 즉시 현재 state 로 한 번 부르고, 리스너에 state 를 넘긴다(Redux 리스너는 인자 없이 불리고 getState() 로 읽는다).2

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: S = preloadedState
  let listeners: Listener<S>[] = []

  function getState(): S {
    return currentState
  }

  function subscribe(listener: Listener<S>): () => void {
    listener(currentState)

    listeners.push(listener)

    return function unsubscribe() {
      listeners = listeners.filter((l) => l !== listener)
    }
  }

  function dispatch(action: A): A {
    currentState = reducer(currentState, action)

    listeners.forEach((listener) => {
      listener(currentState)
    })

    return action
  }

  return {
    subscribe,
    getState,
    dispatch,
  }
}

type Item = {
  body: string
}

type State = {
  items: Item[]
}

type Action = {
  type: 'ADD'
  payload: Item
}

const store = createStore<State, Action>(
  (state, action) => {
    switch (action.type) {
      case 'ADD':
        return {
          ...state,
          items: state.items.concat(action.payload),
        }
      default:
        return state
    }
  },
  {
    items: [],
  }
)

store.subscribe((state) => {
  console.log(state.items)
})

store.dispatch({
  type: 'ADD',
  payload: {
    body: 'hello',
  },
})

Footnotes

  1. Store | Redux: “The only way to change the state inside it is to dispatch an action on it, which triggers the root reducer function to calculate the new state.” ↩

  2. Store | Redux: “It will be called any time an action is dispatched, and some part of the state tree may potentially have changed. You may then call getState() to read the current state tree inside the callback.” ↩

#484

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

리터럴 타입 공통 속성(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

교차 타입을 한 객체로 펼쳐 보여준다 — 매핑 타입이 속성을 다시 모으고, & {} 가 있어야 별칭 이름(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

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

같은 두 시트라도 클라이언트 이동이 <head> 안 순서를 바꾸면 결과가 뒤집힌다.1

명시도가 같으면 뒤에 온 규칙이 이긴다. /b 로 바로 들어오면 head 에 vendor.css(.btn 회색) 다음 shared.css(.btn 파랑)가 와서 파랑이 이긴다. /a 에서 /b 로 이동하면 shared.css 는 /a 가 이미 불러와 앞에 그대로 있고 vendor.css 만 뒤에 붙어 회색이 이긴다. 같은 두 시트인데 순서만 다르다. 순서를 바꾸는 곳은 셋이다. 1 주입: 이미 있는 href 는 건너뛰고 없는 시트만 추가하며 App Router 는 시트를 지우지 않는다. 2 빌드 청킹: 14.2.0 의 새 CSS 청킹(#63157)이 여러 페이지의 CSS 를 한 청크로 병합한다. global CSS 는 병합하지 않게 고친 게 #67373(v15), CSS Modules 의 sideEffects 를 true 로 고정한 문제를 고치는 #70087 은 열려 있다. 3 Pages Router 의 반대 증상: 이동 뒤 서버 렌더 시트(data-n-p)를 지우는데 동적 import CSS 는 이미 있어 건너뛰었으므로 아예 사라진다. #72959 가 동적 CSS 에 data-n-p 를 붙이지 않게 고쳤다.


Footnotes

  1. Introduction to the CSS cascade — MDN: “if there are competing values for a property that are in style block matching selectors of equal specificity and scoping proximity, the last declaration in the style order is applied.” ↩

#479

String.fromCodePoint 는 0xFFFF 를 넘는 코드 포인트도 받아 서로게이트 쌍을 만든다 — fromCharCode 로는 안 된다.1

export function parseNativeEmoji(unified: string): string {
  return unified
    .split('-')
    .map((hex) => String.fromCodePoint(parseInt(hex, 16)))
    .join('')
}
  • unified 문자열을 -로 분리하여 개별 유니코드 코드 포인트를 얻는다.
  • 각 16진수 코드 포인트를 정수로 변환하고, 그에 해당하는 캐릭터를 반환한다.
  • 최종적으로 변환된 캐릭터들을 연결하여 하나의 문자열로 생성한다.

Footnotes

  1. String.fromCodePoint() - MDN: “String.fromCharCode() cannot return supplementary characters (i.e., code points 0x010000 – 0x10FFFF) by specifying their code point.” / “String.fromCodePoint(), on the other hand, can return 4-byte supplementary characters, as well as the more common 2-byte BMP characters, by specifying their code point …” ↩

#477
33 중 8페이지