미리 알면 좋은 개념
본문에 계속 나오는 말들을 먼저 풀어둘게요.
- union(유니온) — "이 값은 A이거나 B다"를 나열한 목록이에요.
'orderNo' | 'total'처럼요. 허용값을 나열해 두면 오타가 컴파일 단계에서 걸려요. - 합성 컴포넌트(compound component) —
Table.Root,Table.Body처럼 여러 조각을 조립해 쓰는 컴포넌트 묶음이에요. 옵션을 boolean prop으로 계속 늘리는 대신 필요한 조각만 골라 배치할 수 있어요. - widening(넓히기) — TypeScript가 값을 보고 타입을 정할 때 일부러 더 느슨한 타입으로 올려 주는 동작이에요. 나중에 값을 바꿔 넣을 걸 대비한 배려인데, 정확한 목록을 뽑고 싶을 때는 방해가 돼요.
- 변성(variance) —
Dog가Animal의 일종일 때Dog[]나Dog를 받는 함수도 그 관계를 물려받는지 정하는 규칙이에요.
오늘 다룬 것들
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| 정렬 키 타입 잠그기 | 컬럼 이름 오타가 런타임까지 가는 걸 막고 싶었어요 | 컬럼 배열에서 union을 파생 + const 타입 파라미터 | 컬럼을 지우면 관련 호출부가 전부 컴파일 에러 |
| 행 타입 전달하기 | 도메인마다 다른 행 타입을 조각들까지 보내고 싶었어요 | 제네릭 함수 안에서 context를 만들고 클로저로 캡처 | as 없이 타입이 살아서 전달돼요 |
| 변성 파고들기 | 제약을 둘 중 뭘로 쓸지 근거가 필요했어요 | 메서드 축약과 함수 프로퍼티의 검사 강도 차이 확인 | 변성 판정에 안 기대는 구조 제약을 선택 |
| 증거 남기기 | "타입으로 막았다"를 검증 가능하게 만들고 싶었어요 | 컴파일되면 안 되는 사용을 타입 테스트로 고정 | 보호가 사라지면 테스트가 빨개져요 |
1. 컬럼 배열에서 정렬 키를 뽑아내기
표 컴포넌트를 만드는데, "지금 어떤 컬럼으로 정렬 중인가"를 담는 값이 그냥 string이었어요. 그러면 sort.columnId = 'ordreNo' 처럼 오타를 내도 컴파일러가 아무 말을 안 해요. 실행해 보고 나서야 정렬이 안 먹는 걸 알게 되죠.
그렇다고 이렇게 손으로 적으면 다른 문제가 생겨요.
const columns = [{ id: "orderNo" }, { id: "total" }];
type ColumnId = "orderNo" | "total"; // 손으로 적은 사본
같은 사실을 두 곳이 각각 소유하게 돼요. 컬럼을 하나 지웠는데 union만 그대로 남으면, 타입은 통과하고 화면만 깨져요.
그래서 컬럼 배열 자체를 정답의 출처로 삼고 union을 거기서 계산하기로 했어요.
type ColumnIdOf<Cols extends readonly { id: string }[]> = Cols[number]["id"];
Cols[number]가 배열 안 모든 원소 타입을 합치고, 거기에 ["id"]를 씌우면 각 멤버에 분배돼서 모든 id의 union이 나와요.
그런데 이것만으로는 작동하지 않아요. 여기가 오늘 제일 놀란 지점이에요.
const cols = [{ id: "orderNo" }, { id: "total" }];
type Bad = (typeof cols)[number]["id"]; // string
const로 선언했는데도 객체 속성은 넓어져요. 그래서 결과가 string이 돼요. 에러도 안 나고, 보호만 조용히 0이 되는 거예요. 이걸 모른 채 "타입으로 막았다"고 생각하고 넘어갔으면 아무것도 못 막는 코드를 배포했을 거예요.
막으려면 함수 쪽에서 강제해야 해요.
function defineColumns<const T extends readonly { readonly id: string }[]>(cols: T): T {
return cols;
}
호출부에서 as const를 쓰는 방법도 있는데, 그건 소비자가 빼먹으면 똑같이 조용히 무력화돼요. 공개 API라면 함수가 강제하는 쪽이 맞아요.
제약에 readonly가 반드시 들어가야 하는 것도 배웠어요. const 추론 결과가 읽기 전용 튜플이라서, 제약이 그냥 배열이면 컴파일이 실패해요. 이건 다행히 시끄럽게 실패해서 금방 알아챌 수 있어요.
→ 값에서 타입 파생하기 — indexed access와 const 타입 파라미터
2. 도메인마다 다른 행 타입을 조각들까지 보내기
표를 여러 도메인이 재사용해야 하는데, 도메인마다 한 줄(Row)의 생김새가 달라요. 이 타입을 context에 실어 나르려고 했더니 문법이 막았어요.
function f<T>(x: T) {} // ✅ 함수는 타입 파라미터 가능
type Box<T> = { v: T }; // ✅ 타입 별칭도 가능
const Ctx<T> = createContext<T>(null); // ❌ const 선언은 타입 파라미터 불가
createContext(...)는 타입이 아니라 값이에요. 부르는 순간 타입이 하나로 확정돼요.
우회 시도 두 개를 먼저 떨어뜨렸어요. unknown으로 만들고 소비 지점마다 as로 변환하면 조각 안에 도메인 타입을 박아 넣게 되어 재사용 목적이 사라져요. 도메인마다 파일을 복사하면 로직 전체가 중복되고요.
답은 제네릭 함수 안에서 context를 만들고, 같이 만든 컴포넌트들이 클로저로 그걸 붙잡게 하는 거였어요.
export function createDataTable<Row>() {
type ContextValue = { columns: readonly ColumnDef<Row>[]; getRowId: (row: Row) => string };
const Ctx = createContext<ContextValue | null>(null);
const useTable = () => {
const c = useContext(Ctx);
if (!c) throw new Error("Table part must be used inside Table.Root");
return c;
};
function Body() {
const { columns, getRowId } = useTable(); // as 없이 Row가 살아 있어요
/* ... */
}
return { Root, Head, Body }; // Ctx 자체는 내보내지 않아요
}
호출 한 번이 곧 독립된 표 한 세트예요. 다른 팩토리의 조각을 섞으면 런타임에 throw하고, 다른 도메인 컬럼을 넘기면 컴파일이 막혀요.
여기서 만난 함정이 하나 있어요. 팩토리를 컴포넌트 본문 안에서 부르면 에러가 안 나요. context 연결도 정상이에요. Provider와 consumer가 같은 클로저에서 나왔으니까요.
진짜 문제는 매 렌더마다 컴포넌트가 새 함수 identity를 갖는다는 거예요. React는 그걸 다른 컴포넌트로 보고 언마운트하고 다시 마운트해요. 증상이 에러가 아니라 "스크롤이랑 입력값이 자꾸 날아감"이라, 원인을 찾기가 훨씬 어려워요.
3. 제약을 뭘로 쓸지 고민하다 변성까지
제약을 readonly ColumnDef<never>[] 로 둘까 readonly { readonly id: string }[] 로 둘까 고민했어요. 앞의 것도 동작은 하는데, 되는 이유가 마음에 걸렸어요. "cell 콜백이 반공변으로 판정되기 때문"이었거든요.
여기서 변성을 제대로 봤어요. 함수 파라미터가 반공변인 이유는 생각보다 직관적이에요. 더 넓게 받는 함수는 좁은 자리에 넣어도 안전하니까요.
const handleAnyAnimal = (x: Animal) => console.log(x.name);
const handleOnlyDog = (x: Dog) => x.bark();
const h1: (x: Dog) => void = handleAnyAnimal; // ✅ 개를 넘겨도 괜찮아요
const h2: (x: Animal) => void = handleOnlyDog; // ❌ 고양이가 오면 bark()가 없어요
그런데 결정적인 걸 발견했어요. strictFunctionTypes를 켜도 메서드 축약 문법은 예외예요.
interface WithMethod {
on(cb: (e: Dog) => void): void; // 이변 — 느슨해요
}
interface WithProperty {
on: (cb: (e: Dog) => void) => void; // 반공변 — 엄격해요
}
읽기에 거의 같아 보이는 두 선언의 검사 강도가 달라요. 그러니까 cell을 메서드 축약으로 바꾸는 순간, 제 제약이 통과하던 근거가 무너지는 거예요. 문법 한 글자에 보호가 걸려 있는 셈이죠.
그래서 변성 판정에 기대지 않는 구조만 요구하는 제약을 골랐어요. "id를 가진 읽기 전용 객체들의 읽기 전용 배열"이면 충분하고, 이건 문법을 어떻게 쓰든 흔들리지 않아요.
4. "타입으로 막았다"를 검증 가능하게
1번에서 겪은 게 계속 마음에 걸렸어요. 보호가 사라져도 아무 소리가 안 난다는 것.
그래서 판정 질문을 하나로 정리했어요.
만들 수 있었던 잘못된 코드 중 무엇이 이제 컴파일되지 않나요?
이 질문에 구체적으로 답할 수 없는 타입 복잡성은 넣지 않기로 했어요. 답할 수 있다면 그 답을 그대로 테스트로 남기고요.
// positive — 정상 호출이 타입 인자 없이 추론돼요
const ok: SortKey = "total";
// negative — 오용 하나당 한 줄
// @ts-expect-error 오타
const typo: SortKey = "ordreNo";
@ts-expect-error가 @ts-ignore와 다른 점이 핵심이에요. 오류가 사라지면 이 줄이 "사용되지 않음"으로 빨개져요. 보호가 없어지는 것 자체가 실패가 되는 구조예요.
여기에 하나 더 배웠어요. 테스트를 만들었다고 끝이 아니라, 그 테스트가 살아 있는지 확인해야 해요. const 타입 파라미터에서 const를 일부러 빼 보고 테스트가 RED가 되는지 봤어요. 안 빨개지면 그 테스트는 아무것도 지키지 않고 있는 거니까요.
케이스는 경계에서 골랐어요
처음엔 부정 케이스를 "최소 3개" 로 잡았어요. 그런데 써 놓고 보니 셋 다 같은 경계를 건드리고 있더라고요. 컬럼 id 오타를 세 번 쓴 거였어요. 개수는 채웠는데 잡는 건 하나였어요.
그래서 런타임 테스트에서 쓰던 경계값 분석(BVA) 을 그대로 타입에 옮겨 봤어요. "최소 2글자"면 1·2·3을 보듯이요. 다만 타입의 경계는 값의 크기가 아니라 타입 격자의 극단이에요.
오늘 만든 컬럼 타입에 실제로 걸린 경계는 셋이었어요.
// 경계 1. union 멤버 / 비멤버
const ok: SortKey = "total";
// @ts-expect-error 없는 컬럼
const bad1: SortKey = "userId";
// 경계 2. 리터럴 / 넓어진 string ← 1번 작업에서 조용히 무너지던 그 지점
// @ts-expect-error 이미 string으로 넓어진 값
const bad2: SortKey = "total" as string;
// 경계 3. 추론 권위 — const 파라미터가 실제로 리터럴을 잡고 있나
const cols = defineColumns([{ id: "orderNo" }, { id: "total" }]);
optional·readonly·tuple 길이·never/any/unknown 축은 이 API에 해당이 없어서 만들지 않았어요. 닫지도 않는 경계에 케이스를 채우는 건 다시 개수 채우기니까요.
그리고 하나 더 배웠어요. BVA 4축 중 타입이 못 덮는 게 있어요. 시간·순서와 부작용 횟수요. 오늘은 타입 설계만 했으니 그 두 축은 손도 못 댄 거고, 런타임 테스트로 넘겨야 할 몫으로 따로 적어 뒀어요.
→ 타입 경계값 분석 — BVA로 타입 테스트 케이스 고르기
오늘의 한 줄
타입으로 막았다는 주장은 "무엇이 이제 컴파일되지 않는가"에 구체적으로 답할 수 있을 때만 참이에요. 그리고 그 답은 문법 한 조각이 빠지면 아무 경고 없이 거짓이 되고요. 오늘 배운 세 가지 — 파생과 const가 세트라는 것, 팩토리를 렌더 중에 부르면 조용히 리마운트한다는 것, 메서드 축약이 검사를 느슨하게 만든다는 것 — 이 전부 실패가 시끄럽지 않다는 공통점을 갖고 있어요.