타입 시스템
- 2.1 편집기를 사용하여 타입 시스템 탐색하기
- 2.2 타입이 값들의 집합이라고 생각하기
- 2.3 타입 공간과 값 공간의 심벌 구분하기
- 2.4 타입 단언보다는 타입 선언 사용하기
- 2.5 객체 래퍼 타입 피하기
- 2.6 잉여 속성 체크의 한계 인지하기
- 2.7 함수 표현식에 타입 적용하기
- 2.8 타입과 인터페이스의 차이점 알기
- 2.9 readonly 사용하기
- 2.10 매핑된 타입을 사용하여 값을 동기화하기
- 2.11 인덱스 시그니처의 대안 알기
타입스크립트의 타입 시스템을 이해하는 가장 강력한 사고 모델은 이거야 — 타입은 가능한 값들의 집합이다. 이거 하나만 잡으면 유니온, 인터섹션, extends, never가 전부 자연스럽게 이해돼.
근데 그 전에, 타입 시스템을 탐색하는 가장 좋은 도구부터 알아야 해. 공식 문서가 아니라 편집기야. VS Code 같은 편집기에서 변수 위에 마우스를 올리면 TS가 추론한 타입을 바로 볼 수 있거든. 조건문 안에서 타입이 어떻게 좁혀지는지, 함수 반환 타입이 뭔지, Go to Definition으로 라이브러리의 .d.ts 파일까지 탐색할 수 있어. 특히 lib.dom.d.ts 같은 파일을 열어보는 습관이 중요한데, 브라우저 API의 타입이 어떻게 선언되어 있는지 볼 수 있고 이게 TS 타입 시스템을 이해하는 가장 좋은 교재가 돼.
다시 집합 모델로 돌아오면, never 타입은 공집합이야. 아무 값도 속하지 않는 타입. 리터럴 타입('hello')은 원소가 하나인 집합이고, string은 가능한 모든 문자열의 집합, string | number는 합집합, unknown은 전체집합이야. **A extends B는 "A는 B의 부분집합"**이라는 뜻이거든. {x: number, y: number}는 {x: number}의 부분집합이야. 왜냐하면 x와 y를 모두 가진 값은 x만 가진 값의 조건도 만족하니까. 속성이 많을수록 집합은 더 작아져 — 직관과 반대로 느껴질 수 있는데, 조건이 많으면 그걸 다 만족하는 값이 적어지는 거잖아. **인터섹션(&)**은 교집합이고 객체 타입의 경우 속성이 합쳐지는 효과가 나타나고, **유니온(|)**은 합집합이야.
TS에서는 같은 이름이 타입일 수도 있고 값일 수도 있어. 이 두 공간이 분리되어 있다는 걸 알아야 혼란을 피할 수 있거든.
interface Cylinder {
radius: number;
height: number;
}
const Cylinder = (radius: number, height: number) => ({ radius, height });
여기서 Cylinder는 타입 공간에서는 인터페이스이고, 값 공간에서는 함수야. class는 타입과 값 둘 다 생성하고, typeof는 문맥에 따라 다르게 동작해 — 값 공간에서는 JS 런타임 연산자, 타입 공간에서는 TS 타입을 추출하지. 어떤 심벌이 타입인지 값인지 헷갈리면, 컴파일 후 JS 코드에 남아있는지 확인해봐. 남아있으면 값, 사라졌으면 타입이야.
변수에 타입을 부여하는 방법이 두 가지 있는데, 타입 선언과 타입 단언이야.
const alice: Person = { name: 'Alice' }; // 타입 선언
const bob = { name: 'Bob' } as Person; // 타입 단언
타입 선언은 값이 해당 타입에 맞는지 검증하고, 타입 단언은 "내가 타입을 더 잘 안다"고 컴파일러에게 알리는 거야. 체크를 건너뛰니까 위험할 수 있지. 가능하면 항상 타입 선언을 써. 타입 단언은 TS가 알 수 없는 정보가 있을 때만 — 대표적으로 DOM 조작에서 document.getElementById('foo') as HTMLInputElement 같은 경우에만 써야 해. ! (non-null assertion)도 타입 단언의 일종인데, 확실하지 않으면 쓰지 마.
JS에서 'hello'.toUpperCase() 같은 걸 쓸 수 있는 이유는, JS가 원시 타입 string을 자동으로 String 객체로 래핑하기 때문이야. TS에서는 이 원시 타입과 래퍼 타입이 별도로 존재하는데 — string vs String, number vs Number. 항상 소문자 원시 타입을 써. 래퍼 타입을 쓰면 이상한 타입 에러가 나기 시작해.
구조적 타이핑에 의하면 추가 속성이 있어도 호환되어야 하잖아. 그런데 객체 리터럴을 직접 대입할 때는 예외적으로 잉여 속성 체크(excess property checking)가 발동해.
interface Room {
numDoors: number;
ceilingHeightFt: number;
}
const r: Room = {
numDoors: 1,
ceilingHeightFt: 10,
elephant: 'present', // 에러! 잉여 속성
};
하지만 중간 변수를 거치면 에러가 안 나. 잉여 속성 체크는 구조적 타이핑과는 별개의 메커니즘이야 — 객체 리터럴을 직접 대입할 때만 동작하는 특별한 체크. 오타나 의도치 않은 속성을 잡아주는 유용한 기능이지만, 항상 작동하는 게 아니라는 걸 알아야 해.
함수 타입을 적용할 때는 함수 표현식이 유리해.
type BinaryFn = (a: number, b: number) => number;
const add: BinaryFn = (a, b) => a + b;
함수 표현식에 타입을 적용하면 매개변수 타입을 일일이 쓸 필요가 없고, 같은 시그니처의 함수가 여러 개 필요할 때 재사용할 수 있어서 반복이 줄어들거든.
type과 interface는 거의 같은 일을 할 수 있지만 차이점이 있어. 인터페이스만 가능한 건 선언 병합(declaration merging) — 같은 이름의 interface를 여러 번 선언하면 자동으로 합쳐지지. 타입만 가능한 건 유니온 타입, 매핑된 타입, 조건부 타입 같은 고급 기법이야. 프로젝트 내에서 일관성을 유지하는 게 가장 중요하고, API 타입 선언에는 interface가 좀 더 유리해 — 사용자가 보강할 수 있으니까.
readonly는 속성이나 배열을 변경 불가능하게 만드는 수식어인데, 얕은(shallow) 보호라는 걸 알아야 해. 객체의 속성이 readonly여도 그 속성이 가리키는 객체의 내부는 변경할 수 있거든. 변경할 필요가 없는 값에는 가능한 한 readonly를 붙여. 어디서 변경이 일어나는지가 타입 시스템에서 명확해지니까.
제네릭과 매핑된 타입을 써서 코드의 반복을 줄이는 패턴도 있어. 차트 컴포넌트에서 어떤 속성이 바뀌면 업데이트해야 하는지를 매핑된 타입으로 강제하는 거지.
const REQUIRES_UPDATE: { [k in keyof ScatterProps]: boolean } = {
xs: true, ys: true, xRange: true, yRange: true, color: true, onClick: false,
};
새 속성이 ScatterProps에 추가되면 REQUIRES_UPDATE에도 추가해야 컴파일이 돼. 타입 체커가 값의 동기화를 강제해주는 패턴이야.
마지막으로 인덱스 시그니처({ [property: string]: string }) 얘기야. 모든 문자열 키에 대해 값을 허용하니까 편리하지만 문제가 많아 — 오타를 못 잡고, 특정 키가 필수인지 표현 못 하고, 자동완성도 안 돼. 정확한 타입을 쓸 수 있으면 그걸 쓰고, 동적인 데이터라면 Record<K, V>, 키별로 다른 값 타입이 필요하면 매핑된 타입을 써. 인덱스 시그니처가 적합한 경우는 CSV 파싱 결과처럼 런타임에 어떤 키가 올지 정말 모르는 경우뿐이야.
정리
2장 읽고 기억할 거 세 가지:
- 타입은 값들의 집합이다. 이 사고 모델이 잡히면 유니온, 인터섹션,
extends,never가 전부 자연스럽게 이해된다. - 타입 공간과 값 공간은 분리되어 있다. 같은 이름이 다른 공간에서 다른 의미를 가질 수 있다.
- 타입 단언보다 타입 선언, 인덱스 시그니처보다 구체적인 타입. TS를 잘 쓴다는 건 타입 시스템이 최대한 많이 체크하게 만드는 것.