namespace × setting_key 조합이 많은 설정 API를 만들면서, namespace·key·value 타입을 따로따로 정의하다 일관성이 깨졌다. 매핑 하나만 정의하고 나머지는 거기서 derive 하는 패턴으로 풀었다.
type UserSettingValueMap = {
DEFAULTS: {
warehouse: { _v: 1; warehouse_id?: number };
staff: { _v: 1; staff_id?: number };
};
COLUMNS: {
order_list: { _v: 1; columns: string[] };
};
};
namespace → key → value 구조를 타입으로 그대로 표현. 모든 derived 타입은 여기서 나온다.
type UserSettingNamespaces = keyof UserSettingValueMap;
// 'DEFAULTS' | 'COLUMNS'
keyof T로 최상위 키 union 추출 (Index Type Query).
type UserSettingKeys<
NS extends UserSettingNamespaces = UserSettingNamespaces,
= NS extends UserSettingNamespaces // ← ❶ 분배 트리거
? keyof UserSettingValueMap[NS] & string // ← ❷ namespace 안 키 union
: never;
❶ 없이 keyof UserSettingValueMap[NS] & string만 쓰면, UserSettingKeys (NS 생략) 호출 시 결과가 never가 된다. 이유:
keyof UserSettingValueMap['DEFAULTS' | 'COLUMNS']
// = keyof (Map['DEFAULTS'] | Map['COLUMNS']) ← T[A|B]는 union에 분배
// = keyof Map['DEFAULTS'] & keyof Map['COLUMNS'] ← keyof (A|B)는 교집합!
// = ('warehouse'|'staff') & ('order_list')
// = never
T[A|B]는 union에 분배되는데, keyof (A|B)는 두 타입의 공통 속성만(=교집합) 반환하는 규칙이 합쳐져 의도와 정반대 결과를 낳는다.
❶의 NS extends X ? A : B 형태 + naked type parameter면 TS가 conditional을 union NS에 분배한다 (Distributive Conditional Types). 그러면:
UserSettingKeys
// = UserSettingKeys<'DEFAULTS'> | UserSettingKeys<'COLUMNS'> ← 분배
// = ('warehouse' | 'staff') | 'order_list'
// = 'warehouse' | 'staff' | 'order_list' ✓
여기서 extends는 분기 목적이 아니라 분배를 트리거하는 장치다. false branch (: never)는 사실상 unreachable인 dummy.
type UserSettingValue<
NS extends UserSettingNamespaces,
KEY extends UserSettingKeys, // ← NS×KEY 조합 검증
= UserSettingValueMap[NS][KEY]; // ← 이중 indexed access
type W = UserSettingValue<'DEFAULTS', 'warehouse'>;
// { _v: 1; warehouse_id?: number }
KEY 제약을 UserSettingKeys로 걸어둬서 NS×KEY 조합이 매핑에 실제 있을 때만 통과한다.
async function getUserSetting<
NS extends UserSettingNamespaces,
K extends UserSettingKeys,
(ns: NS, key: K): Promise<UserSettingValue<NS, K> | null> { ... }
const w = await getUserSetting('DEFAULTS', 'warehouse');
// w: { _v: 1; warehouse_id?: number } | null
const x = await getUserSetting('DEFAULTS', 'order_list');
// ^^^^^^^^^^^ ❌ 'DEFAULTS'에 없는 키
const y = await getUserSetting('COLUMNS', 'order_list');
// y: { _v: 1; columns: string[] } | null
조합 검증 + 반환 타입 narrowing이 한 번에 일어난다. 호출처에서 narrowing 코드가 사라진다.
type UserSettingValueMap = {
DEFAULTS: { ... };
COLUMNS: { ... };
UI: { // ← 추가
theme: { _v: 1; mode: 'light' | 'dark' };
};
};
UserSettingNamespaces에 'UI' 자동 포함, UserSettingKeys<'UI'> → 'theme', getUserSetting('UI', 'theme') 호출 즉시 가능. 매핑 한 곳 수정 = 모든 derived 자동 갱신, 이게 SSOT의 핵심 효용.
마무리
source of truth 매핑 하나에서 namespace·key·value를 모두 derive 하는 패턴. 사용한 TS 기능은 keyof T (Index Type Query), T[K] (Indexed Access), <T = X> (Generic Defaults), T extends U ? A : B (Conditional Types), 그리고 위 conditional이 union에 분배되는 Distributive Conditional Types.
설정·액션·이벤트·메시지처럼 "키 → 페이로드" 구조면 어디든 적용 가능한 패턴이다.