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')
)
import { match } from 'ts-pattern'
type Format = 'webp' | 'jpg'
type Params = {
id: string
quality: keyof typeof QUALITY_MAP
format: Format
}
const QUALITY_MAP = {
player_background: '0',
video_frames_start: '1',
video_frames_middle: '2',
video_frames_end: '3',
lowest_quality: 'default',
medium_quality: 'mqdefault',
high_quality: 'hqdefault',
standard_quality: 'sddefault',
unscaled_resolution: 'maxresdefault',
}
const BASE_URL = 'https://i.ytimg.com'
const VI = (format: Format) =>
match(format)
.with('jpg', () => 'vi')
.otherwise(() => ['vi', format].join('_'))
export function getThumbnail({ id, quality, format }: Params) {
return [BASE_URL, VI(format), id, QUALITY_MAP[quality]]
.join('/')
.concat(`.${format}`)
}
두 개의 오버로딩된 메서드에서 각각의 반환 타입을 명시적으로 추출하기
type Year = {
year(): number
year(u: string): string
}
type ReturnTo<T, R> = T extends R ? R : never
type GetYear = ReturnTo<Year['year'], () => number>
type SetYear = ReturnTo<Year['year'], (u: string) => string>
템플릿 리터럴 타입을 활용하여 타입 정의하기
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>
)
}
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 ExpressionDefimport type { A } from 'a'
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>
)}
/>
)
}
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)
}}
/>
)
}
import * as React from 'react'
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
type T = React.ComponentProps<'div'>
type P = Prettify<T>
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>
)
}
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>) {
return <img src={src} alt="" />
}
function App() {
return (
<>
{/* @ts-expect-error */}
<Image src="" />
<Image src="ab" />
</>
)
}
TypeScript 중첩 객체 타입 부분 수정
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 } }
tsconfig 핵심: target, lib, module
TypeScript (.ts)
↓
lib: 타입 체크 시 뭘 알고 있나? (Promise, Map 등)
↓
target: 문법을 얼마나 낮출 건가? (ES2020 → ?. 그대로, ES2019 → 삼항연산자로)
↓
module: import/export를 뭘로? (CommonJS → require, ESNext → import)
↓
JavaScript (.js)
target vs lib 분리 이유: 문법(syntax)과 API(runtime)는 다름
?.→ target이 변환 (문법)Promise→ 폴리필이 해결 (API)
{
"target": "ES2019", // 하위호환 (optional chaining 이전)
"module": "ESNext", // 트리쉐이킹 가능
"lib": ["ES2020", "DOM"], // 타입은 넉넉하게
"moduleResolution": "Node"
}
TS 버전 올리면서 target 그대로 두면 Webpack4 같은 구형 번들러에서 파싱 실패할 수 있음.
TypeScript 제네릭 기본값 - <T extends Type = DefaultValue>
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
}
가장 자주 사용되는 케이스를 기본값으로 설정하면 제네릭을 항상 명시하는 번거로움 줄어듦.
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 {}
여러 단계의 비동기 UI 업데이트를 setLoading → setProgress → setLoading(false)로 흩뿌리지 않고 async generator의 yield 시퀀스로 시간 순서대로 표현. 원본 트릭은 @ericclemmons.
type FlowEvent<P extends string, D> = { phase: P; data: D }
async function* loadingFlow(
signal: AbortSignal,
): AsyncGenerator<FlowEvent<'starting' | 'slow' | 'done', string>> {
yield { phase: 'starting', data: 'Starting…' }
await wait(1000, signal)
yield { phase: 'slow', data: 'Taking longer than usual' }
await wait(2000, signal)
yield { phase: 'done', data: 'Got it 🎉' }
}
원본의 빈 자리 → 소비 레이어 책임
원본 데모는 4가지가 비어 있음. hook이든 actor든 wrapping 레이어에서 채워야 함:
- cancellation: generator에
AbortSignal주입 →await대상(wait,fetch)이 signal-aware해야 진짜 취소됨 - 재진입: 새 실행 시 직전 iterator abort
- error path: generator 내부 throw → 상태 노출
- unmount/teardown: cleanup으로 in-flight iterator abort
- stale closure: 최신 클로저 참조 (hook은 ref, actor는 input)
- typed:
phase/data모두 좁힘
어디에 맞는가
generator는 시간이 코드의 한 방향(↓)으로만 흐르는 모델. 그 모양에 맞는 시나리오:
- 멀티 단계 loading 메시지 (위
loadingFlow) - progressive search — tier별로 점진적 결과 yield, tier 경계가 자연스러운 cancellation point, debounce 불필요
- optimistic mutation —
{ phase: 'optimistic' | 'reconciled' | 'rolledBack', data }전이를 4개 콜백 대신 한 함수로
WeakMap의 키를 “입력 배열의 참조 자체”로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.
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 {
let index = indexCache.get(items)
if (!index) {
index = buildIndex(items) // 캐시 미스일 때만 빌드
indexCache.set(items, index)
}
return index.get(name) ?? null
}
- 같은 참조로 재호출 → cache hit, O(1)
- 데이터가 새 참조로 교체(refetch 등) → 자동 miss → 새 index 빌드. 옛 index는 옛 items와 함께 GC 대상
- 키가 weak reference라 items가 어디서도 안 잡히면 entry도 같이 사라짐. 일반
Map이면 참조가 영구히 붙들려 메모리 누수
전제: 호출 측 items 참조가 stable해야 작동. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK)
참고
prop 프리셋을 데이터로 중앙화하는 타입 헬퍼를, 가로축 excess(프로퍼티명 오타) 검출 · 세로축 개별 리터럴 보존으로 좌표에 놓으면 단일 named 헬퍼로 네 칸을 다 차지하는 점은 없다가 한눈에 보인다.
우상단(둘 다 ✓)엔 satisfies와 Exact-never가 있지만, 각각 “어노테이션 반복” / “메시지 나쁨”을 대가로 치른다.
type TextStyleProps = Omit<TextProps, 'children'>
// ① 제네릭 항등 — 리터럴 O, excess 검출 X (조용히 T로 흡수)
const createTextStyles = <T extends Record<string, TextStyleProps>>(s: T): T =>
s
// ② 키 제네릭 — excess O(메시지 좋음), 키 O, 개별 리터럴은 유니온으로 넓어짐 ← 추천
const createTextStyles = <K extends string>(
s: Record<K, TextStyleProps>,
): Record<K, TextStyleProps> => s
// ③ Exact-never — 리터럴·excess O, 단 에러가 "not assignable to never"
type Exact<V> = {
[P in keyof V]: P extends keyof TextStyleProps ? TextStyleProps[P] : never
}
const createTextStyles = <T extends Record<string, TextStyleProps>>(
s: T & { [K in keyof T]: Exact<T[K]> },
): T => s
// ④ 인라인 satisfies — 리터럴·excess·메시지 다 O, 단 named 헬퍼 아님 + 어노 반복
const guideStyles = {
title: { type: 'body1_700', color: COLORS.GRAY_90 },
} satisfies Record<string, TextStyleProps>
값 오타(type: 'nope')·키 오참조(styles.titel)는 ①②③④ 전부 잡는다. 갈리는 건 프로퍼티명 오타와 개별 type 리터럴뿐.
구조적 이유: excess 검출은 고정 target을, 리터럴 보존은 *제네릭 캡처(T)*를 요구해 서로 반대로 당긴다. satisfies만 컴파일러가 특별 취급해 둘을 한 번에 주지만, 그 대가가 “구문이지 헬퍼가 아님”이다.
권장: 엄격성이 목표면 ② (잃는 건 거의 안 쓰는 개별 type 리터럴). 개별 리터럴이 진짜 필요하면 ④. color가 required면 ①도 프로퍼티명 오타가 “필수 누락”으로 걸려 갭이 거의 사라진다.
프리셋을 컴포넌트가 아니라 prop 데이터로 다루면 래퍼 컴포넌트 비용(displayName·리마운트·추가 fiber)을 회피하고, override는 평범한 JSX 스프레드로 투명하다.
dayjs 날짜 표기 카탈로그
날짜 표기 통일에 매핑 객체가 필요 없다 — dayjs는 포맷 문자열이 곧 키이자 값이다.1
i18n은 이름표(인사)와 내용(Hello)이 달라서 짝지어주는 객체가 필수였다. 그 습관이 여기까지 따라온 것이다.
Schema
| 이름 | 형태 | 역할 |
|---|---|---|
DATE_FORMATS |
readonly string[] (as const) |
허용 표기의 단일 출처 |
DateFormat |
(typeof DATE_FORMATS)[number] |
배열에서 파생된 닫힌 union |
formatDate |
(date, format: DateFormat) => string |
유일한 진입점 |
Examples
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)
편입 기준
여러 곳에서 통일이 필요한 표기만 배열에 담는다. 일회성('M.D' 등)은 raw
dayjs().format()으로 인라인 유지한다. 이 기준이 없으면 배열이 잡동사니 enum이 된다.
Guard
카탈로그에 있는 표기를 raw로 다시 쓰면 경고 — no-raw-date-format
(no-restricted-syntax). 아직 작성하지 않았다. 타입은 formatDate를 우회한 호출을
막지 못하므로, 이 규칙이 없으면 카탈로그의 단일 출처 지위는 관행일 뿐이다.
타입이 못 잡는 전제
ddd(요일)는 진입점에서 dayjs.locale('ko')를 호출해야 한국어로 나온다.2
호출하지 않으면 Mon·Tue처럼 영어로 조용히 샌다 — 예외도 타입 오류도 없다.
YYYY.MM.DD(ddd)는 그래서 다른 표기와 달리 런타임 전제를 가진 항목이다.
기각: 빌드타임 hoist / tagged-template 캐시
포맷 문자열을 빌드 때 미리 계산해 상수로 끌어올리거나(hoist) 결과를 캐시해두는 방법을 검토했다. 고유 패턴이 수십 개 규모라 중복 제거로도 컴파일 최적화로도 얻을 게 없다. 닫힌 타입 + 함수 하나로 충분하다. 패턴 수가 수백 단위로 늘면 재검토한다.