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
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
-
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.” ↩
-
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.” ↩
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
-
객체 속성에 넣은 리터럴은 보통
string으로 넓어지는데(<T>(x: { a: T })에{ a: 'ab' }→string), 들어갈 자리의 타입(여기선Exclude<T, ''>)이string제약을 가지면 리터럴로 남긴다. TypeScript v5.9.3checker.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.” ↩
리터럴 타입 공통 속성(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
-
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.” ↩
-
판별 속성이 없는 union(
{ a: number } | { b: string })은{ a: 1, b: 'x' }를 받는다 — 3.5 릴리스 노트도 “partial overlap is still permitted” 라 한다. 막히는 건 검사 대상을 판별 속성으로 먼저 좁히기 때문이다: TypeScript v5.9.3checker.ts—hasExcessPropertiesreducedTarget = findMatchingDiscriminantType(source, target, …). ↩
교차 타입을 한 객체로 펼쳐 보여준다 — 매핑 타입이 속성을 다시 모으고, & {} 가 있어야 별칭 이름(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
-
& {}를 빼면 에러 메시지에 별칭 이름이 그대로 나온다(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.” ↩
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
후보가 여럿이면 자리에 따라 고른다 — 화살표 함수를 직접 쓰면 매개변수를 다 적었는지에 따라 자리가 바뀐다.2
TRow 를 두 번째로 쓰는 prop 은 NoInfer 로 줍기에서 뺀다 (TS 5.4+).
type Props<TRow> = {
rows: TRow[]
renderRow: (row: TRow, index: number) => React.ReactNode
selectedRow?: NoInfer<TRow>
}
extends Record<string, any> 는 원시값만 막는다.
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
-
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.” ↩
-
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”. ↩
같은 두 시트라도 클라이언트 이동이 <head> 안 순서를 바꾸면 결과가 뒤집힌다.1
- v14.1.4 → v14.2.0 — 이 사이에 CSS 청킹이 바뀌었다 (#63157)
- avoid merging global css in a way that leaks into other chunk groups · #67373 — v15 canary 부터, 14.2.x 엔 없다
- fix(build): fix css modules out of order issue from libraries · #70087 — 열림, webpack 쪽 수정을 기다리는 중
Footnotes
-
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.” ↩
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
-
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 …” ↩