이펙티브 타입스크립트 2판 Day 3: 타입 반복을 줄이는 리뷰.
Effective TypeScript 2판의 Item 11–15를 바탕으로 excess property check, 함수식 타입, type vs interface, readonly, 타입 반복 제거를 프론트엔드 코드 리뷰 관점에서 정리합니다.
Day 2는 Effective TypeScript 2판의 Item 6–10을 묶어서 읽습니다. 세부 item 이름을 외우기보다 하나의 질문으로 정리하면 이렇습니다.
타입스크립트가 보는 타입 세계를 내가 직접 탐색하고 설명할 수 있는가?
Day 1에서 TypeScript가 JavaScript 위에 얹히는 정적 검사 계층이라는 경계를 봤습니다. 오늘은 그 검사기가 실제로 타입을 어떻게 생각하는지 쪽으로 들어갑니다.
오늘 잡을 포인트는 네 가지입니다.
as를 안전 경계가 아니라 우회 경계로 보기타입을 “변수 옆에 붙은 이름표”로만 보면 TypeScript의 오류 메시지가 답답해집니다. string, number, union, literal type, unknown, never가 서로 어떤 관계인지 감이 잘 안 옵니다.
더 나은 관점은 타입을 가능한 값의 집합으로 보는 것입니다.
type A = string;
type B = "ready" | "error";
type C = never
Post Q&A
이펙티브 타입스크립트 2판 Day 2: 타입을 값의 집합으로 보기 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!
이 타입들을 집합처럼 보면 다음과 같습니다.
| 타입 | 가능한 값의 범위 |
|---|---|
"ready" | 문자열 값 하나 |
"ready" 또는 "error" | 두 개의 문자열 값 |
string | 모든 문자열 값 |
unknown | TypeScript가 표현할 수 있는 거의 모든 값 |
never | 가능한 값이 없음 |
이 관점 하나만 있어도 union, narrowing, assignability, exhaustiveness를 훨씬 덜 신비롭게 볼 수 있습니다.
TypeScript를 공부할 때 책이나 문서만 읽으면 금방 추상적이 됩니다. 실제 코드를 놓고 “지금 컴파일러가 이 값을 뭐라고 추론하는지”를 확인해야 합니다.
예를 들어 이런 코드를 봅니다.
const status = "ready";
let phase = "ready";
두 값은 같은 문자열로 초기화됐지만 타입은 다르게 추론됩니다.
// const status: "ready"
// let phase: string
const는 재할당되지 않으므로 literal type으로 좁게 잡을 수 있습니다. let은 나중에 다른 문자열이 들어올 수 있으므로 string으로 넓게 잡힙니다.
이런 차이는 에디터 hover, “Go to Definition”, “Quick Info”, tsc --noEmit 같은 도구로 바로 확인할 수 있습니다. 실무에서는 이 탐색 습관이 꽤 중요합니다. 타입 에러를 억지로 없애기 전에 먼저 물어야 합니다.
TypeScript는 지금 이 값을 어떤 타입으로 보고 있지?
내 의도보다 넓게 추론됐나, 좁게 추론됐나?
특히 객체와 배열에서는 이 차이가 자주 나옵니다.
const route = { kind: "post", slug: "ts-day-2" };
// route.kind는 "post"가 아니라 string으로 넓어질 수 있다.
객체의 프로퍼티는 나중에 바뀔 수 있으므로 literal이 바로 보존되지 않는 경우가 있습니다. 이때 as const를 쓰면 좁아지지만, 무작정 붙이면 수정 가능성까지 막아버립니다. 먼저 추론 결과를 확인하고 의도를 결정하는 것이 순서입니다.
타입을 집합으로 보면 extends나 할당 가능성도 더 자연스럽습니다.
type Small = "ready" | "error";
type Big = string;
const a: Small = "ready";
const b: Big = a;
Small의 값은 모두 string 안에 들어갑니다. 그래서 Small 값을 string 변수에 넣는 것은 안전합니다.
반대는 아닙니다.
const anyString: string = "pending";
const phase: Small = anyString;
// Type 'string' is not assignable to type 'Small'.
string에는 "ready", "error" 말고도 너무 많은 값이 들어올 수 있습니다. 그러니 좁은 집합에 바로 넣을 수 없습니다.
이 감각은 API 상태 모델링에서 중요합니다.
type LoadState = "idle" | "loading" | "success" | "error";
이 타입은 “문자열 네 개 중 하나”입니다. 단순히 문자열에 이름을 붙인 게 아닙니다. 가능한 상태를 네 개로 제한한 것입니다. 그래서 리뷰에서는 이렇게 물을 수 있습니다.
이 상태 타입은 실제 UI 상태 집합을 충분히 표현하는가?
반대로 너무 넓게 string으로 열어둔 곳은 없는가?
unknown과 never를 집합으로 이해하기unknown과 never는 처음 보면 낯설지만, 집합 관점에서는 단순합니다.
unknown은 가장 넓은 쪽입니다. 어떤 값이든 들어올 수 있지만, 바로 사용할 수는 없습니다.
function renderValue(value: unknown) {
value.toUpperCase();
// Property 'toUpperCase' does not exist on type 'unknown'.
}
unknown은 불편하지만 정직합니다. 외부에서 들어온 값이 무엇인지 모르면 먼저 좁히라고 요구합니다.
function renderValue(value: unknown) {
if (typeof value === "string") {
return value.toUpperCase();
}
return String(value);
}
반대로 never는 가능한 값이 없는 타입입니다. 보통 모든 경우를 처리했는지 확인할 때 쓸모가 있습니다.
type Result =
| { type: "success"; data: string }
| { type: "error"; message: string };
function render(result: Result) {
switch (result.type) {
case "success":
return result.data;
case "error":
return result.message;
default: {
const _exhaustive: never = result;
return _exhaustive;
}
}
나중에 Result에 { type: "pending" }이 추가되면 default의 never 체크가 깨집니다. 이건 좋은 실패입니다. UI 상태가 늘어났는데 렌더링 분기가 따라오지 않았다는 신호니까요.
TypeScript에는 타입으로만 존재하는 이름과 런타임 값으로 존재하는 이름이 있습니다. 둘은 문맥에 따라 다릅니다.
interface User {
name: string;
}
const User = {
label: "사용자",
};
interface User는 type space에 있습니다. 런타임 JavaScript로 남지 않습니다. const User는 value space에 있습니다. 실제 실행 코드에 남습니다.
헷갈리는 대표 사례는 typeof입니다.
const config = {
mode: "dark",
retry: 3,
};
type Config = typeof config;
여기서 typeof config는 런타임의 typeof 연산이 아니라 type space에서 값의 타입을 가져오는 문법입니다. 같은 단어라도 위치에 따라 의미가 달라집니다.
클래스는 더 헷갈립니다. 클래스 이름은 타입으로도 쓰이고 값으로도 쓰입니다.
class Dialog {
open() {}
}
let instance: Dialog;
const Constructor = Dialog;
instance: Dialog의 Dialog는 인스턴스 타입입니다. const Constructor = Dialog의 Dialog는 런타임 생성자 값입니다. 이 차이를 모르면 typeof Dialog, InstanceType, React 컴포넌트 props 타입을 읽을 때 자주 막힙니다.
리뷰에서는 import도 확인해야 합니다.
import type { User } from "./types";
import { createUser } from "./factory";
타입으로만 쓰는 것은 import type으로 분리하면 번들에 남을 값 import를 줄이고, type/value 경계를 더 분명히 할 수 있습니다.
as는 TypeScript에게 “내가 더 잘 아니까 이렇게 봐줘”라고 말하는 문법입니다. 값을 바꾸지 않습니다.
const input = document.querySelector("input") as HTMLInputElement;
input.value;
이 코드는 편하지만, 실제 DOM에 input이 없으면 런타임에서 깨질 수 있습니다. as HTMLInputElement는 null을 없애는 마법이 아닙니다. 타입 검사기를 설득했을 뿐입니다.
더 안전한 코드는 런타임 체크를 같이 둡니다.
const input = document.querySelector("input");
if (!(input instanceof HTMLInputElement)) {
throw new Error("input element not found");
}
input.value;
물론 모든 as가 나쁜 것은 아닙니다. TypeScript가 추론하지 못하지만 개발자가 런타임 조건을 이미 확인한 경우도 있습니다. 문제는 as가 빠른 해결책처럼 보인다는 점입니다.
const user = JSON.parse(raw) as User;
이 코드는 컴파일러를 조용하게 만들지만, raw가 정말 User인지 검증하지 않습니다. 외부 입력, API 응답, localStorage, URL query처럼 경계 밖에서 들어온 값에는 단언보다 검증이 먼저입니다.
코드 리뷰 질문은 이렇게 바꿀 수 있습니다.
이 as는 TypeScript의 한계를 보정하는가?
아니면 실제로 필요한 런타임 검증을 숨기고 있는가?
Day 2의 내용은 타입 이론처럼 보이지만, 실제 리뷰에서는 꽤 구체적인 체크리스트가 됩니다.
상태, props, API 응답 타입이 이상할 때 바로 타입을 덧씌우지 말고 hover로 현재 추론 결과를 확인합니다. 타입이 너무 넓으면 생성 지점을 고치고, 너무 좁으면 확장될 가능성이 있는지 봅니다.
const tabs = ["home", "settings", "profile"];
이 배열이 단순 문자열 배열인지, 세 개의 탭 값으로 제한되어야 하는지에 따라 타입 모델이 달라집니다.
type ModalState = "open" | "closed";
이 모델이 충분한지 봐야 합니다. 로딩 중, 제출 중, 에러 상태가 실제 UI에 있다면 두 값만으로는 부족할 수 있습니다. 타입을 값의 집합으로 보면 누락된 상태를 질문하기 쉬워집니다.
unknown을 any로 도망치지 않았는가외부 입력은 처음에는 unknown으로 받는 편이 정직합니다. 바로 any로 바꾸면 타입 시스템의 도움을 잃습니다.
function handleMessage(message: unknown) {
// 좁힌 뒤 사용한다.
}
as가 검증을 대체하고 있지 않은가DOM query, JSON parse, URL query, 서버 응답 뒤에 붙은 as를 특히 봅니다. 그 값이 실제로 그 타입인지 확인하는 코드가 없다면 타입 안정성이 아니라 타입 침묵일 수 있습니다.
TypeScript를 잘 쓰려면 타입 문법을 많이 외우는 것보다, 컴파일러가 보는 세계를 탐색하는 습관이 먼저입니다.
tsc는 타입 시스템을 관찰하는 도구다.unknown은 넓지만 안전하게 좁히라고 요구한다.never는 가능한 값이 없어서 exhaustiveness 체크에 유용하다.as는 값을 바꾸지 않고 타입 검사기를 설득할 뿐이다.Day 2의 핵심은 타입을 “붙이는 것”이 아니라 “가능한 값의 범위를 설계하고 검증하는 것”입니다.
Effective TypeScript 2판의 Item 6–10을 바탕으로 타입 시스템을 탐색하는 법, 타입을 값의 집합으로 보는 관점, type/value space, 타입 단언의 경계를 코드 리뷰 관점에서 정리합니다.