TypeScript Type System Guide
TypeScript Type System Guide
타입스크립트를 “문법 모음”이 아니라 “타입 시스템”으로 이해하기 시작하는 구간만 분리한 문서다. 범위는 타입스크립트 이해하기, 함수와 타입이다.
읽기 경로
함께 읽기
- NestJS Techniques Guide: DTO, pipe, serialization에서 타입 좁히기와 함수 시그니처 이해가 중요하다.
- TypeORM + NestJS Guide: optional query DTO와 repository API를 연결할 때 타입 계층과 narrowing 감각이 중요하다.
이 문서에서 다루는 섹션
Part 3: 타입스크립트 이해하기
Part 4: 함수와 타입
Part 3: 타입스크립트 이해하기
타입스크립트 이해하기
타입스크립트는 단순히 : number를 붙이는 언어가 아니다.
값들의 집합을 어떻게 모델링하고, 그 집합 사이의 관계를 어떻게 판단하는지를 이해해야 진짜로 편해진다.
이 파트의 핵심 질문
- 어떤 값이 어떤 타입에 들어갈 수 있는가?
- 왜 어떤 대입은 되고 어떤 대입은 안 되는가?
- 추론, 단언, 좁히기는 각각 무엇을 하는가?
타입은 집합이다
타입은 특정 조건을 만족하는 값들의 집합으로 볼 수 있다.
let num: number;
num = 10;
num = 20;
number 타입은 모든 숫자 값의 집합이다.
"hello" 같은 리터럴 타입은 값 하나만 가진 아주 작은 집합이다.
왜 이 관점이 중요한가
- 유니온은 집합의 합집합으로 볼 수 있다.
- 인터섹션은 교집합처럼 이해할 수 있다.
- 상위 타입/하위 타입 관계도 집합 포함 관계로 생각하면 훨씬 명확해진다.
타입 계층도와 함께 기본타입 살펴보기
타입은 더 넓은 타입과 더 좁은 타입 사이의 계층을 가진다.
대략적인 감각
"hello"는string보다 좁다string은unknown보다 좁다never는 어떤 타입보다도 좁다
let a: unknown;
let b: string = "hello";
a = b; // 가능
// b = a; // 불가능
핵심 타입들의 역할
| 타입 | 역할 |
|---|---|
unknown |
가장 안전한 최상위 타입 |
any |
검사 우회용 특수 타입 |
never |
값이 절대 존재하지 않는 타입 |
실무 메모
- 외부 입력의 출발점은
unknown - 절대 도달하면 안 되는 상태는
never any는 계층을 흐리는 예외적 탈출구
객체 타입의 호환성
타입스크립트는 구조적 타입 시스템을 사용한다.
즉, 이름보다 구조가 더 중요하다.
type Dog = { name: string; age: number };
type Pet = { name: string };
let dog: Dog = { name: "콩", age: 3 };
let pet: Pet = dog; // 가능
Dog는 Pet이 요구하는 구조를 포함하므로 대입 가능하다.
자주 드는 의문
이름이 다른데 왜 대입이 되지?
타입스크립트는 nominal typing이 아니라 structural typing이 기본이기 때문이다.
trade-off
- 유연하고 재사용성이 좋다.
- 하지만 너무 비슷한 구조가 우연히 호환되어 버려 의도치 않은 대입이 생길 수도 있다.
- 도메인 의미가 중요하면 branded type 같은 별도 기법을 고려할 수 있다.
대수 타입
대수 타입은 여러 타입을 조합해 새로운 타입을 만드는 방식이다.
유니온 타입
type Input = string | number;
둘 중 하나를 허용한다.
인터섹션 타입
type HasName = { name: string };
type HasAge = { age: number };
type Person = HasName & HasAge;
둘 다 동시에 만족해야 한다.
실무 메모
- 유니온은 분기와 상태 표현에 강하다.
- 인터섹션은 여러 제약을 합치는 데 유용하다.
- 인터섹션을 남발하면 오류 메시지가 매우 복잡해질 수 있다.
타입 추론
타입스크립트는 가능한 한 개발자가 적지 않아도 타입을 추론하려고 한다.
let num = 10; // number
let title = "hello"; // string
좋은 추론과 나쁜 추론
- 지역 변수, 반환값, 제네릭 호출에서는 대체로 잘 된다.
- 빈 배열, 넓은 객체 초기값, 복잡한 콜백에서는 의도보다 넓거나 좁게 추론될 수 있다.
const arr = []; // any[] 또는 never[] 같은 애매한 상태를 만들기 쉽다
추천 기준
- 추론이 명확하면 생략
- API 경계, 공개 함수, 복잡한 객체에는 명시적 타입 추가
- “헷갈리면 적어라”가 실무에서는 더 안전하다
타입 단언
단언은 “타입스크립트야, 이 값의 타입을 내가 더 잘 안다”고 말하는 행위다.
const input = document.getElementById("name") as HTMLInputElement;
주의할 점
- 단언은 검사가 아니라 주장이다.
- 잘못 단언하면 런타임에서 그대로 터진다.
- 좁히기 대신 단언으로 습관적으로 우회하면 타입스크립트의 이점을 크게 잃는다.
추천 기준
- DOM, 라이브러리 타입 한계, 프레임워크 내부 훅 등에서 제한적으로 사용
- 가능하면 type guard나 runtime check를 먼저 고려
타입 좁히기
좁히기는 넓은 타입을 더 구체적인 타입으로 줄이는 과정이다.
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
좁히기에 쓰는 도구
typeofinstanceof- 속성 존재 검사 (
"kind" in value) - truthy/falsy 검사
- 사용자 정의 타입 가드
실무 메모
unknown을 제대로 쓰려면 narrowing을 익혀야 한다.- API 응답, 에러 객체, 폼 입력, 쿼리 파라미터에서 거의 매번 등장한다.
서로소 유니온 타입
서로소 유니온은 각 타입이 공통의 구분자 필드를 갖고, 그 값이 겹치지 않는 유니온이다.
type Loading = { state: "loading" };
type Success = { state: "success"; data: string[] };
type ErrorState = { state: "error"; message: string };
type FetchState = Loading | Success | ErrorState;
분기 예시
function render(state: FetchState) {
switch (state.state) {
case "loading":
return "loading...";
case "success":
return state.data.join(", ");
case "error":
return state.message;
}
}
왜 강력한가
- 상태 모델링이 명확해진다.
- 각 분기에서 필요한 필드만 안전하게 접근 가능하다.
- reducer, API 상태, UI 상태, 백엔드 응답 모델에 매우 잘 맞는다.
never exhaustive check 패턴
서로소 유니온이 진짜 강해지는 지점은 switch 분기 누락을 컴파일 단계에서 잡을 수 있다는 점이다.
function assertNever(x: never): never {
throw new Error(`처리되지 않은 케이스: ${JSON.stringify(x)}`);
}
function render(state: FetchState) {
switch (state.state) {
case "loading":
return "loading...";
case "success":
return state.data.join(", ");
case "error":
return state.message;
default:
return assertNever(state);
}
}
왜 유용한가
FetchState에 새 케이스를 추가했는데switch를 안 고치면 바로 타입 오류가 난다.- 런타임 버그를 “빠진 분기” 수준에서 미리 막을 수 있다.
- reducer, 비동기 상태 머신, API 응답 상태 모델에서 특히 효과가 크다.
실무 메모
never를 “값이 없는 타입”으로만 외우면 감이 약하다.
실무에서는 “모든 케이스를 다 처리했는지 확인하는 마지막 안전장치”로 기억하는 편이 훨씬 잘 남는다.
Part 4: 함수와 타입
함수 타입
함수도 값이므로 타입을 가진다.
매개변수 타입과 반환값 타입이 함수 타입의 핵심이다.
function add(a: number, b: number): number {
return a + b;
}
실무 메모
- 공개 함수, 서비스 메서드, helper 함수는 반환 타입까지 명시하는 편이 좋다.
- 내부 짧은 콜백까지 전부 명시할 필요는 없다.
함수 타입 표현식과 호출 시그니쳐
함수의 형태를 타입으로 따로 뽑아낼 수 있다.
함수 타입 표현식
type Add = (a: number, b: number) => number;
const add: Add = (a, b) => a + b;
호출 시그니처
type Operation = {
(a: number, b: number): number;
description: string;
};
언제 무엇을 쓰나
- 단순 함수 모양이면 함수 타입 표현식
- 함수이면서 추가 속성이 필요한 객체면 호출 시그니처
함수 타입의 호환성
함수도 구조적으로 비교된다. 다만 매개변수와 반환 타입의 방향이 헷갈리기 쉽다.
기본 감각
- 반환 타입은 더 구체적인 쪽이 대체로 안전하다.
- 매개변수는 반대로 더 넓게 받을 수 있어야 안전하다.
이 지점은 이론적으로는 variance 이야기로 이어진다.
실무에서는 “내가 기대하는 인수보다 더 좁게만 받는 함수는 위험하다” 정도로 기억하면 된다.
함수 오버로딩
입력 형태에 따라 다른 시그니처를 제공하고 싶을 때 쓴다.
function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
if (typeof value === "string") {
return Number(value);
}
return String(value);
}
trade-off
- API 의도를 명확히 드러낼 수 있다.
- 하지만 구현 시그니처와 선언 시그니처를 함께 관리해야 해서 복잡해질 수 있다.
- 단순 유니온 + narrowing으로 해결 가능하면 그쪽이 더 읽기 쉬운 경우도 많다.
사용자 정의 타입가드
직접 타입 좁히기 함수를 만들 수 있다.
type Admin = { role: "admin"; permissions: string[] };
type User = { role: "user"; name: string };
function isAdmin(value: Admin | User): value is Admin {
return value.role === "admin";
}
사용 예시
function printUser(value: Admin | User) {
if (isAdmin(value)) {
console.log(value.permissions.join(", "));
} else {
console.log(value.name);
}
}
실무 메모
- 복잡한 DTO 유니온, API 응답 상태, 권한 모델에서 특히 유용하다.
- 단, 구현이 잘못되면 타입스크립트는 그 거짓말을 믿는다. guard 함수의 정확성이 매우 중요하다.