28. 유효한 상태만 표현하는 타입을 지향하기
유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발하게 된다.
interface State {
pageText: string;
isLoading: boolean;
error?: string;
}
function renderPage(state: State) {
if (state.error) {
return "Error";
} else if (state.isLoading) {
return "Loading";
}
return "page";
}
async function changePage(state: State, newPage: string) {
state.isLoading = true;
try {
const response = await fetch(newPage);
if (!response.ok) {
throw new Error();
}
const text = await response.text();
state.isLoading = false;
state.pageText = text;
} catch (e) {
state.error = "" + e;
}
}
위 코드의 renderPage는 분기 조건이 명확히 분리되어 있지 않다.
isLoading이 true이고 동시에 error 값이 존재하면 로딩 중인지 오류가 발생한 건지 명확히 구분할 수 없다.
changePage에도 문제점이 많다.
- 오류가 발생했을 때 state.isLoading을 false로 설정하는 로직이 없다.
- state.error를 초기화하지 않았기 때문에, 페이지 전환 중에 로딩 메시지 대신 과거의 오류 메시지를 보여 주게 된다.
- 페이지 로딩 중에 사용자가 페이지를 바꿔 버리면 어떤 일이 벌어질지 예상하기 어렵다.
즉, State 타입은 isLoading이 true이면서 error 값이 설정되는 무효한 상태를 허용하는 것이다.
무효한 상태가 존재하면 두 함수 모두 제대로 구현할 수 없다.
코드를 좀 더 개선해보자.
interface RequestPending {
state: "pending";
}
interface RequestError {
state: "error";
error: string;
}
interface RequestSuccess {
state: "ok";
pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;
interface State {
currentPage: string;
requests: { [page: string]: RequestState };
}
function renderPage(state: State) {
const { currentPage } = state;
const requestState = state.requests[currentPage];
switch (requestState.state) {
case "pending":
return "Loading";
case "error":
return "Error";
case "ok":
return "Page";
}
}
async function changePage(state: State, newPage: string) {
state.requests[newPage] = { state: "pending" };
state.currentPage = newPage;
try {
const response = await fetch(newPage);
if (!response.ok) {
throw new Error();
}
const pageText = await response.text();
state.requests[newPage] = { state: "ok", pageText };
} catch (e) {
state.requests[newPage] = { state: "error", error: "" + e };
}
}
네트워크 요청 과정 각각의 상태를 명시적으로 모델링하는 태그된 유니온을 사용했다.
타입 관련 코드가 길어졌지만 무효한 상태를 허용하진 않는다.
두 함수의 모호함도 완전히 사라졌다. 현재 페이지가 무엇인지 명확하며, 모든 요청은 정확히 하나의 상태로 맞아 떨어진다.
29. 사용할 때는 너그럽게, 생성할 때는 엄격하게
보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있다.
매개변수는 타입의 범위가 넓어도 되지만, 반환 타입은 더 구체적이어야 한다.
매개변수 타입의 범위가 넓으면 편리하지만, 반환 타입의 범위가 넓으면 불편하다.
interface CameraOptions {
center?: LngLat;
zoom?: number;
bearing?: number;
pitch?: number;
}
type LngLat =
| {
lng: number;
lat: number;
}
| { lon: number; lat: number }
| [number, number];
type LngLatBounds =
| {
norttheast: LngLat;
southwest: LngLat;
}
| [LngLat, LngLat]
| [number, number, number, number];
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
function handleCamera(bounds: LngLatBounds) {
const camera = viewportForBounds(bounds);
const {
center: { lat, lng }, // 'LngLat | undefined' 형식에 속성이 없습니다.
zoom,
} = camera;
}
center의 타입이 LngLat | undefined로 추론되며, zoom의 타입이 number | undefined로 추론된다.
근본적인 문제는 viewportForBounds의 타입 선언이 사용될 때뿐만 아니라 만들어질 때에더 너무 자유롭다는 것이다.
camera 값을 안전한 타입으로 사용하는 유일한 방법은 유니온 타입의 각 요소별로 코드를 분기하는 것이다.
유니온 타입의 요소별 분기 방법은, 기본 형식을 구분하는 것이다.
array와 array-like의 구분을 위해 LngLat와 LngLatLike로 구분할 수 있다.
완전하게 정의된 Camera와 부분적으로 정의된 Camera로 구분할 수 있다.
interface Camera {
center: LngLat;
zoom: number;
bearing: number;
pitch: number;
}
interface CameraOptions extends Omit<Partial<Camera>, "center"> {
center?: LngLatLike;
}
interface LngLat {
lng: number;
lat: number;
}
type LngLatLike = LngLat | { lon: number; lat: number } | [number, number];
type LngLatBounds =
| {
norttheast: LngLatLike;
southwest: LngLatLike;
}
| [LngLatLike, LngLatLike]
| [number, number, number, number];
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;
function handleCamera(bounds: LngLatBounds) {
const camera = viewportForBounds(bounds);
setCamera(camera);
const {
center: { lat, lng }, // 정상
zoom,
} = camera;
}
center는 LatLng로, zoom은 number로 잘 추론된다.
여기서 만약 bounds를 생성하는 함수가 추가된다면 LngLatBounds와 LngLatBoundsLike도 구분해줘야 할 것이다.
선택적 속성과 유니온 타입은 반환 타입보다 매개변수 타입에 더 일반적이다.
매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)를 같이 두자.
30. 문서에 타입 정보를 쓰지 않기
코드와 주석의 정보가 맞지 않으면 잘못된 상태인 것이다.
주석과 변수명에 타입 정보를 적는 것은 피해야 한다.
타입 선언이 중복되는 것으로 끝나면 다행이고, 타입 정보 자체에 모순이 발생할 수도 있다.
타입스크립트는 타입을 코드로 표현하는 것이 주석보다 더 낫다.
주석은 코드와 동기화되지 않지만, 타입 구문은 타입 체커가 정보를 동기화한다.
변수명을 ageNum으로 하는 것보다는 age로 하고 타입을 number로 명시하는 게 좋다.
그러나 단위가 있는 숫자들처럼, 타입이 명확하지 않은 경우는 변수명에 단위 정보를 포함하는 것을 고려하자. (timeMs, temperatureC)
31. 타입 주변에 null 값 배치하기
한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관련되도록 설계하면 안 된다.
값이 전부 null이거나 전부 null이 아닌 경우로 분명히 구분해야 다루기 쉽다.
최솟값과 최댓값을 계산하는 예시를 보자.
function extent(nums: number[]) {
let min, max;
for (const num of nums) {
if (!min) {
min = num;
max = num;
} else {
min = Math.min(min, num);
max = Math.max(max, num); // max가 'number | undefined'
}
}
return [min, max];
}
// strictNullChecks가 false라면 타입 체커를 통과한다. 반환 타입은 number[]로 추론된다.
// strictNullChecks가 true라면 반환 타입은 (number | undefined)[]로 추론된다.
위 코드는 버그와 함께 설계적 결함이 있다.
- 최솟값/최댓값이 0인 경우 값이 덧끠워진다. extent([0, 1, 2])의 결과는 [0, 2]가 아닌 [1, 2]가 된다.
- nums 배열이 비어 있다면 [undefined, undefined]를 반환한다.
- min, max는 동시에 둘 다 undefined이거나 둘 다 undefined가 아니지만, 이는 타입 시스템에서 표현할 수 없다.
undefined를 min에서만 제외하고 max에서는 제외하지 않았기 때문에 발생한 오류다.
min과 max를 한 객체 안에 넣고 null이거나 null이 아니게 하면 된다.
function extent(nums: number[]) {
let result: [number, number] | null = null;
for (const num of nums) {
if (!result) {
result = [num, num];
} else {
result = [Math.min(num, result[0]), Math.max(num, result[1])];
}
}
return result;
}
const [min, max] = extent([0, 1, 2])!;
클래스에서도 마찬가지이다.
class UserPosts {
user: UserInfo | null;
posts: Post[] | null;
constructor() {
this.user = null;
this.posts = null;
}
async init(userId: string) {
return Promise.all([
async () => (this.user = await fetchUser(userId)),
async () => (this.posts = await fetchPostsForUser(userId)),
]);
}
getUserName() {
// ...?
}
}
네트워크 요청이 로드되는 되는 동안 user와 posts 속성은 null이다.
한 시점에서, user와 posts의 타입의 경우의 수는 네 가지다.
결국 null 체크가 난무하고 버그를 양상하게 된다.
클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 해야 한다.
class UserPosts {
user: UserInfo;
posts: Post[];
constructor(user: UserInfo, posts: Post[]) {
this.user = user;
this.posts = posts;
}
static async init(userId: string): Promise<UserPosts> {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPostsForUser(userId),
]);
return new UserPosts(user, posts);
}
getUserName() {
return this.user.name;
}
}
만약 데이터가 부분적으로 준비되었을 때 작업을 시작해야 한다면, null과 null이 아닌 경우의 상태를 모두 다루어야 한다.
(null인 경우가 필요한 속성은 프로미스로 바꾸면 안 된다. 매우 복잡해진다.)
API 작성 시에도 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야 한다.
32. 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기
유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 주의해야 한다.
유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 이해하기 쉽다.
interface Layer {
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
layout이 LineLayout이면서 paint가 FillPaint인 경우는 없다. 이런 조합은 오류가 발생하기 쉽다.
각각 타입의 계층을 분리된 인터페이스로 둬야 한다.
interface FillLayer {
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
layout과 paint 속성이 잘못된 조합으로 섞이는 경우를 방지할 수 있다.
이러한 패턴의 가장 일반적인 예시는 태그된 유니온(구분된 유니온)이다.
태그는 타입스크립트가 제어 흐름을 분석할 수 있도록 한다.
interface Layer {
type: "fill" | "line" | "point";
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
type: 'fill'과 함께 LineLayout과 PointPaint 등이 쓰이는 것은 말이 되지 않는다.
Layer을 인터페이스의 유니온으로 변환하자.
interface FillLayer {
type: "fill";
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
type: "line";
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
type: point;
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
type 속성은 태그이다. 인터페이스의 유니온으로 정의한 경우 Layer 타입의 범위를 좁히기도 용이하다.
각 타입의 속성들 간의 관계를 제대로 모델링하자.
여러 개의 선택적 필드가 동시에 값이 있거나 동시에 undefined인 경우도 태그된 유니온 패턴이 잘 맞다.
interface Person {
name: string;
placeOfBirth?: string;
dateOfBirth?: Date;
}
placeOfBirth와 dateOfBirth는 관련되어 있지만 타입 정보에는 표시되어 있지 않다.
두 속성을 하나의 객체로 모으자.
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}
API 결과 처럼 타입의 구조를 손 댈 수 없는 상황이면, 인터페이스의 유니온을 사용해서 속성 사이의 관계를 모델링 할 수 있다.
interface Name {
name: string;
}
interface PersonWithBirth extends Name {
placeOfBirth: string;
dateOfBirth: Date;
}
type Person = Name | PersonWithBirth;