- TypeScript 타입변수 추론 진단
Prettify— 교차 타입을 펼쳐 보기- 판별 유니온으로 props 조합 제한
Exclude로 빈 문자열 막기- 오버로드별 반환 타입 꺼내기
- 템플릿 리터럴 타입으로 이름 파생
기초 및 개념 이해
React와 TypeScript
- How to write a React Component in TypeScript
- How to Simplify Component Imports with TypeScript Namespaces
타입 시스템 심화 및 패턴
- Implementing a type-safe object builder in TypeScript
- Branded Types | Learning TypeScript
- https://effect.website/docs/guides/style/branded-types
- TypeScript enums: use cases and alternatives
- Pattern Matching in TypeScript with Record and Wildcard Patterns
- Did you know you can write your own typesafe React router in 500 lines? · OlegWock
성능 및 효율적인 코드 작성
- Performance · microsoft/TypeScript Wiki · GitHub
- Efficient Typescript
- One Thing Nobody Explained To You About TypeScript
스타일 가이드 및 실전 활용
- TypeScript Style Guide
- DefinitelyTyped/README.ko.md at master · DefinitelyTyped/DefinitelyTyped
- Effective TypeScript
- Blogged Answers: Learning and Using TypeScript as an App Dev and a Library Maintainer
Node.js 및 빌드 관련
함수형 프로그래밍과 TypeScript
마이그레이션 사례
- DevTools architecture refresh: migrating DevTools to TypeScript - Chrome Developers1
- Migrating Puppeteer to TypeScript - Chrome Developers
- ts-migrate: A Tool for Migrating to TypeScript at Scale | by Sergii Rudenko | The Airbnb Tech Blog | Medium2
- Migrating millions of lines of code to TypeScript3
- Etsy’s Journey to TypeScript - Code as Craft4
Footnotes
-
Chrome DevTools 및 Puppeteer를 TypeScript로 마이그레이션한 경험에 대한 설명 ↩
-
대규모 JavaScript 코드베이스를 TypeScript로 자동 마이그레이션하는 도구인 “ts-migrate”를 소개하는 Airbnb 엔지니어링 팀의 블로그 게시물 ↩
-
Flow에서 TypeScript로 마이그레이션한 경험을 설명하고 오픈소스 flow-to-typescript-codemod 도구에 대한 링크를 포함하는 Stripe 엔지니어링 팀의 블로그 게시물 ↩
-
Etsy 엔지니어링 팀이 대규모 웹 애플리케이션에서 TypeScript를 채택한 방법, 직면한 문제 및 얻은 이점을 설명 ↩
interface MathFn {
(a: number, b: number): number
}
const sum: MathFn = (a, b) => a + b
Footnotes
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')
)
오버로드는 ReturnType·infer 로 하나를 고를 수 없다 — 마지막 시그니처만 본다. 시그니처를 알면 할당 가능한지로 고른다.
T extends R 는 “R 처럼 불러도 T 가 받아내나?“를 묻는다. 오버로드된 T 는 시그니처 중 하나만 받아내면 된다. 시그니처 하나가 받아내는 조건1은 셋이다.
- R 이 넘기는 인자보다 더 요구하지 않는다 — 남는 인자는 버리면 된다
- 받는 인자의 타입이 맞는다
- 돌려주는 값이 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
-
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”. ↩
-
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.” ↩
템플릿 리터럴 타입으로 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
-
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”. ↩
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로 구성
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()
}
}
단순 초기화만 필요하면 일반 생성자가 더 직관적. 복잡한 생성 로직에만 사용.
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 ExpressionDefverbatimModuleSyntax 를 켜면 type 없는 import 는 출력에 그대로 남는다 — 타입만 가져올 땐 import type 으로 표시해야 한다(안 하면 TS1484).1
import type { A } from 'a'
- GitHub - privatenumber/fix-verbatim-module-syntax: CLI tool to auto-fix verbatimModuleSyntax errors
- TypeScript: TSConfig Reference - Docs on every TSConfig option
- Exploring TypeScript 5 features: Smaller, simpler, faster - LogRocket Blog
Footnotes
-
아래 TSConfig Reference 의
verbatimModuleSyntax: “any imports or exports without atypemodifier are left around. Anything that uses thetypemodifier is dropped entirely.” ↩
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”. ↩
교차 타입을 한 객체로 펼쳐 보여준다 — 매핑 타입이 속성을 다시 모으고, & {} 가 있어야 별칭 이름(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.” ↩
리터럴 타입 공통 속성(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, …). ↩
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.” ↩
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
-
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.” ↩
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
-
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.” ↩
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로 자동 생성
간단한 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
},
}
}
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
}
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 {}
isLoading 없이, onClick 에서 yield 만 한다. 핸들러를 async generator 로 받아 훅이 for await 로 돌며 yield 할 때마다 값을 바꾼다 — setLoading → setProgress → setLoading(false) 가 시간 순서대로 한 함수에 들어간다.
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)} />
언제 — 시간이 코드의 한 방향(↓)으로만 흐를 때: 멀티 단계 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 로만 바뀌면 된 것이다.
- 출처: @ericclemmons
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) 스캔이 사라진 자리
}
같은 배열 참조로 두 번 불러 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. 새 표기가 필요해지면
- 여러 곳에서 통일이 필요한 표기인지 먼저 본다. 일회성(
'M.D'등)이면 rawdayjs().format()으로 둔다 — 이 기준이 없으면 배열이 잡동사니 enum이 된다. DATE_FORMATS에 추가한다. 그게 전부다.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)만 런타임 전제를 가진 항목이다.