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 스프레드로 투명하다.