[effective typescript] 4장. 타입 설계
아이템 28. 유효한 상태만 표현하는 타입을 지향하기
타입을 잘 설계하면 코드를 직관적으로 작성할 수 있다.
타입 설계할 떄 어떤 값을 포함하고 어떤 값을 제외할지 신중하게 생각해야 한다.
효과적으로 타입 설계하려면 유효한 상태만 표현할 수 있는 타입 만들어야 한다.
무효한 상태를 포함하는 문제 코드 예시
// 페이지 상태 설계
interface PageState {
pageText: string;
isLoading: boolean;
error?: string;
}
const renderPage = (state: PageState) => {
if (state.error) {
return `Error! Unable to load ${currentPage}: ${state.error}`;
} else if (state.isLoading) {
return `Loading ${currentPage}`;
}
return `<h1>${currentPage}</h1>\n${state.pageText}`;
};
코드에서 문제가 되는 부분
renderPage 함수 문제점
isLoading이true이고 동시에error값이 존재하는 경우에 대한 분기 조건이 불명확하다. (로딩 상태인지, 오류 발생한 상태인지)
changePage 함수 문제점
const changePage = async (state: PageState, newPage: string) => {
state.isLoading = true;
try {
const response = await fetch(getUrlForPage(newPage));
if (!response.ok) {
throw new Error(`Unable to load ${newPage}:${response.statusText}`);
}
const text = await response.text();
state.isLoading = false;
state.pageText = text;
} catch (e) {
state.error = " " + e;
}
};
오류 발생했을 때
state.isLoading을false로 설정하는 로직이 누락되었다.state.error초기화 하지 않아서 페이지 전환 중에 로딩 메세지 대신 과거 에러 메세지 보여준다.페이지 로딩 중에 사용자가 페이지를 바꿔버리면 어떤 일이 벌어질지 예상하기 어렵다. 새 페이기에 오류가 뜨거나, 응답이 오는 순서에 따라 두 번째 페이지가 아닌 첫 번째 페이지로 전환될 수 있다.
무효한 상태 허용되지 않도록 코드 개선하기
- 하나의 인터페이스로 관리하던 부분을 각 상태 인터페이스로 분리해서 작성하고, 유니온 타입으로 타입 정의 명확하게 변경함.
interface RequestPending {
state: "pending";
}
interface RequestError {
state: "error";
error: string;
}
interface RequestSuccess {
state: "ok";
pageText: string;
}
type RequestState = RequestError | RequestPending | RequestSuccess;
interface State {
currentPage: string;
requests: { [page: string]: RequestState };
}
const renderPage = (state: State) => {
const { currentPage } = state;
const requestState = state.requests[currentPage];
switch (requestState.state) {
case "pending":
return `Loading ${currentPage}`;
case "error":
return `Error! Unable to load ${currentPage}: ${requestState.error}`;
case "ok":
return `<h1>${currentPage}</h1>\n${requestState.pageText}`;
}
};
const changePage = async (state: State, newPage: string) => {
state.requests[newPage] = { state: "pending" };
state.currentPage = newPage;
try {
const response = await fetch(getUrlForPage(newPage));
if (!response.ok) {
throw new Error(`Unable to load ${newPage}:${response.statusText}`);
}
const pageText = await response.text();
state.requests[newPage] = { state: "ok", pageText };
} catch (e) {
state.requests[newPage] = { state: "error", error: "" + e };
}
};
요약
유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발한다.
코드 길어지더라도 유효한 상태만 표현하는 타입을 지향하기
아이템 29. 사용할 때는 너그럽게, 생성할 때는 엄격하게
- 함수의 매개변수 타입의 범위는 넓어도 되지만, 반환 타입은 구체적이어야 한다.
문제 코드 예시
declare const setCamera = (camera : CameraOptions) : void;
declare const viewportForBounds = (bounds : LngLatBounds) : CameraOptions;
type LngLat = {lng : number; lat: number} | {log: number; lat: number} | [number,number]
interface CameraOptions {
center?:LngLat;
zoom?:number;
bearing?:number;
pitch?:number;
}
type LngLatBounds = {northeast : LngLat, southwest : LngLat} | [LngLat,LngLat] | [number,number,number,number]
// 뷰포트 조절하고 새 뷰포트를 URL에 저장하는 함수
const focusOnFeature = (f:Feature) => {
const bounds = calculateBoundingBox(f);
const camera = viewportForBounds[bounds]
setCamera(camera)
const {center : {lat,lng},zoom} = camera // 타입 에러 발생, lat, lng 속성 없음
zoom; // 타입이 number | undefined
}
viewportForBounds함수의 자유도가 너무 높다.매개변수 타입의 범위가 넓으면 사용하기 편리하지만, 반환 타입의 범위가 넓으면 불편하다.
코드 개선하기
유니온 타입
LngLat타입을 배열과 배열 같은것 타입으로 구분하기CameraOptions를 엄격하게 정의된Camera타입과Camera타입이 부분적으로 정의된 버전으로 구분하기 (!!!!)
interface LngLat {lng : number; lat: number}
type LngLatLike = LngLat | {log: number; lat: number} | [number,number]
type LngLatBounds = {northeast : LngLatLike, southwest : LngLatLike} | [LngLatLike,LngLatLike] | [number,number,number,number]
// 엄격한 타입
interface Camera {
center:LngLat;
zoom:number;
bearing:number;
pitch:number;
}
// 엄격한 Camera 타입의 조건 완화해서 느슨한 타입 만들기
interface CameraOptions extends Omit<Partial<Camera>,"center">{
cetner?:LngLatLike
}
declare const setCamera = (camera : CameraOptions) : void;
declare const viewportForBounds = (bounds : LngLatBounds) : Camera;
요약
보통 배개변수 타입은 반환 타입에 비해 범위가 넓은 편이다. 선택적 속성과 유니온 타입은 반환 타입보다 매개변수 타입에 더 일반적이다.
매개변수와 반환 타입을 재사용하기 위해서 기본 형태(반환 타입)와 느슨한 형태(매새변수 타입)을 도입하는 것이 좋다. (!!!!!!!!)
아이템 30. 문서에 타입 정보를 쓰지 않기
주석이 문제가 되는 경우
코드를 보면 알수있는 정보를 작성한 경우
불필요하게 장황한 주석을 남기는 경우
코드와 주석 정보가 맞지 않는 경우
- 실무에서 주석과 로직이 다른 코드를 본 적이 있다. 이런 경우는 본래 주석의 목적인 코드의 이해도를 높이기 보다 혼란을 초래하게 된다. 그 경험을 통해서 주석도 코드 변경사항과 일치하기 위해 관리를 해야한다는 걸 배웠다. (코드와 구현의 동기화 필수)
주석 활용 코드 예시
// good case
/** 애플리케이션 또는 특정 페이지의 전경색을 가져옵니다. */
const getForegroundColor = (page?: string): Color => {};
- 특정 매개변수를 설명하고 싶다면
JSDoc의@param구문 사용
변경되지 않아야 하는 값을 표현할 때는 readonly 사용하기
- 주석은 강제성이 없지만,
readonly는 강제성 부여할 수 있다.
// bad case
/** numbs를 변경하기 않습니다 */
const sort1 = (nums: number[]) => {
nums[0] = 1; // 타입 체커 통과
};
// good case
const sort2 = (nums: readonly number[]) => {
nums[0] = 1; // 타입 에러 발생
};
주석과 동일하게 변수에도 타입 정보 넣지 말기
- 타입으로 확인 가능한 부분은 주석에 포함하지 않듯이, 변수 이름에도 타입 정보 넣지 말기 (단위가 있는 숫자는 예외!)
예시
ageNum보다는age
예외 케이스
타입이 명확하지 않은 경우에는 변수명에 단위 정보 포함하는 것도 좋다.
time보다는timeMstemperature보다는temperatureC
요약
주석과 변수명에 타입 정보 적는 것 피하기. 타입 정보에 모순이 발생하게 된다.
타입이 명확하지 않는 경우 변수명에 단위 정보 포함하는 것이 좋다.
아이템 31. 타입 주변에 null 값 배치하기
타입에 null을 추가하는 이유
- 값이 전부 null이거나 전부 null이 아닌 경우가 값이 섞여 있을 때보다 다루기 쉽다.
숫자들의 최솟값과 최댓값을 계산하는 extent 함수 예시
const 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);
}
}
return [min, max];
};
코드 문제점
// 1번 예시
console.log(extent([0, 1, 2])); // [ 1, 2 ]
// 2번 예시
console.log(extent([])); // [ undefined, undefined ]
최솟값이나, 최대값이 0인 경우 값이 덧씌어져버린다.
빈 배열이 들어오는 경우,
[ undefined, undefined ]리턴한다.undefined를 포함하는 객체는 다루기 어려워서 절대 권장하지 않는다.반환 타입이
(number | undefined)[]라서extent호출하는 곳마다 오류 형태로 나타난다.
단일 객체 사용해서 코드 개선하기
min,max를 각각 값 체크하지 말고min,max를 한 객체 안에 넣고null이거나null이 아니게 하기
// 함수 시그니처 const extent: (nums: number[]) => [number, number] | null
const 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;
};
console.log(extent([0, 1, 2])); // [ 0, 2 ]
console.log(extent([])); // null
extent 함수 사용 예시
null 아님 단언(!)사용한 경우
const [min, max] = extent([0, 1, 2])!;
const span = max - min;
- if 구문으로 값 체크하는 경우
const range = extent([0, 1, 2]);
if (range) {
const [min, max] = range;
const span = max - min;
}
null과 null이 아닌 값을 섞어서 사용하는 클래스 예시
type UserInfo = any;
type Post = any;
// 사용자와 사용자 포럼 게시글 나타내는 클래스
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상태다. (둘 다null이거나,null이 아니거나, 둘 중 하나만null이거나)속성값의 불확실성으로
null체크가 난무해지기 떄문에 클래스 모든 메서드에 나쁜 영향을 준다.
개선하기
필요한 데이터가 모두 준비된 후에 클래스 만들도록 변경
type UserInfo = any;
type Post = any;
class UserPosts {
user: UserInfo | null;
posts: Post[] | null;
constructor() {
this.user = null;
this.posts = null;
}
static async Init(userId: string): Promise<UserPosts> {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPostsForUseR(userId),
]);
return new UserPosts(user, posts);
}
getUserName() {}
}
요약
한 값의
null여부가 다른 값의null여부에 암시적으로 관련되도록 설계하면 안된다.API 작성 시에는 반환 타입을 큰 객체로 만들고, 반환 타입 전체가
null이거나null이 아니게 만들어야 사람과 타입 체커 모두에서 명료한 코드가 된다.클래스를 만들 떄는 필요한 모든 값이 준비되었을 때 생성하여
null이 존재하지 않도록 하는게 좋다. (!!!)strictNullChecks는null값과 관련데 문제점 찾아낼 수 있어서 반드시 필요하다.
아이템 32. 유니온의 인터페이스보다 인터페이스의 유니온을 사용하기
태그된 유니온 패턴 예시 1
type 속성 '태그' 사용해서
Layer타입 범위 좁히기어떤 데이터타입을 태그된 유니온으로 표현할 수 있다면 그렇게 하는 게 보통 좋다.
// bad case
interface Layer {
layout: FillLayout | LineLayout | PointLayout; // 레이아웃 제어
paint: FillPaint | LinePaint | PointPaint; // 스타일 제어
}
// good case
interface FillLayer {
type: "fill";
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
type: "line";
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
type: "paint";
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
const drawLayer = (layer: Layer) => {
if (layer.type === "fill") {
const { paint } = layer; // 타입이 FillPaint
const { layout } = layer; // 타입이 FillLayout
} else if (layer.type === "line") {
const { paint } = layer; // 타입이 LinePaint
const { layout } = layer; // 타입이 LineLayout
} else {
const { paint } = layer; // 타입이 PointPaint
const { layout } = layer; // 타입이 PointLayout
}
};
태그된 유니온 패턴 예시 2
여러 개의 선택적 필드가 동시에 값이 있거나
undefined인 경우 사용한다.서로 연관된 속성은 하나의 객체로 모으는 게 더 나은 설계이다. 이 방법은
null값을 경계로 두는 방법과 비슷하다. (아이템 31에서 다룰 예정)
// bad case - 관련된 필드임에도 불구하고 타입에는 반영되지 않음
interface Person {
name: string;
placeOfBirth?: string;
dateOfBirth?: string;
}
// good case - 연관된 두개의 속성 하나의 객체로 모음
interface Person2 {
name: string;
birth?: {
place: string;
date: string;
};
}
// use case
const alanT: Person2 = {
name: "Alan Turing",
birth: {
place: "Londone",
}, // place만 있고 date 없는 경우 오류 발생
};
// Person 객체를 매개변수로 받는 함수 - birth하나만 체크하면 된다
const eulogize = (p: Person2) => {
console.log(p.name);
const { birth } = p;
if (birth) {
console.log(`was born on ${birth.date} in ${birth.place}`);
}
};
API의 결과라서 타입 구조를 변경할 수 없는 경우 해결 방법
인터페이스의 유니온 사용해서 속성 사이의 관계 모델링하기
타입 정의를 통해 속성 간의 관계를 더 명확하게 만들기
- 두 타입으로 구분한 방법을 실무에서 써봐야겠다!
interface Name {
name: string;
}
interface PersonWithBirth extends Name {
placeOfBirth: string;
dateOfBirth: Date;
}
type Person = Name | PersonWithBirth;
const eulogize = (p: Person) => {
if ("placeOfBirth" in p) {
p; // 타입이 PersonWithBirth
const { dateOfBirth } = p; // 타입이 Date
}
};
요약
인터페이스에서 유니온 타입 속성을 여러개 가지는 경우, 속성 간의 관계가 분명하지 않아서 실수가 자주 발생하므로 주의하기
유니온의 인터페이스보다 인터페이스의 유니온이 더 좋은 설계이다.
타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그 속성 추가하는 태그된 유니온 패턴 자주 사용하기
아이템 33. string 타입보다 더 구체적인 타입 사용하기
string 남발 코드 예시 (stringly typed)
string은any처럼 잘못 사용하면 무효한 값을 허용하고, 타입 간의 관계도 감추어 버린다.
// bad case
interface Album {
artist: string;
title: string;
releaseDate: string; // YYYY-MM-DD
recordingType: string; // "live" 또는 "studio"
}
const kingOfBlue: Album = {
artist: "mallang",
title: "kind dog",
releaseDate: "October 19th, 2023" // Album 타입에 정의된 날짜 형식과 다름
recordingType: "Studio", // 대문자 사용
};
const recordRelease = (title: string, date: string) => {};
recordRelease(kingOfBlue.releaseDate, kingOfBlue.artist);
코드의 문제점
string타입이라서 타입 정의할 때 의도된 형식이 아니어도 문제 발생하지 않음recordRelease함수의 매개변수가 모두string이기때문에, 매개변수 순서가 바뀌어도 에러 발생 하지 않음
타입 좁혀서 코드 개선하기
realeaseDate는Date객체 사용 -> 날짜 형식으로 제한recordingType은 유니온 타입으로 선언하기
/** 이 녹음은 어떤 환경에서 이루어졌는지? */
type RecordType = "live" | "studio";
// good case
interface Album {
artist: string;
title: string;
releaseDate: Date;
recordingType: RecordType;
}
const getAlbumsOfType = (recordingType: RecordType): Album[] => {
return [];
};
위 코드의 장점
타입을 명시적 정의했기 떄문에 값이 다른 곳으로 전달되어도 타입 정보 유지된다.
타입을 명시적으로 정의하고, 해당 타입의 의미를 설명하는 주석을 넣을 수 있다. (!!!)
keyof연산자로 더욱 세밀하게 객체 속성 체크 가능
// 배열에서 필드 값만 추출하는 함수 - Underscore 라이브러리 pluck 함수
const pluck = (records, key) => {
return records.map((record) => record[key]);
};
// 타입 시그니처 추가
// 제네릭 타입 사용해서 key의 타입을 records의 유효한 키로 좁히기
// 반환 타입도 추론된다.
// T[keyof T] : T 객체내의 가능한 모든 값의 타입
const pluck = <T>(records: T[], key: keyof T) => {
return records.map((record) => record[key]);
};
const releaseDates1 = pluck1(albums, "releaseDate"); // 타입이 (string | Date)[]
relaseDates1의 타입을 Date[]로 좁히기
- 두 번째 매개 변수인 키의 타입을 구체화하기 위해서 제내릭 매개변수
K도입하기
const pluck2 = <T, K extends keyof T>(records: T[], key: K): T[K][] => {
return records.map((record) => record[key]);
};
const releaseDates2 = pluck2(albums, "releaseDate"); // 타입이 Date[]
요약
모든 문자열 허용하는
string보다 구체적인 타입 사용하기변수의 범위를 보다 정확하게 표현할 때는 문자열 리터럴 타입의 유니언 사용하기
객체의 속성 이름을 함수 매개변수로 받을 때는
string보다keyof T사용하기