[이펙티브 타입스크립트] 4장 타입 설계
아이템 28 유효한 상태만 표현하는 타입을 지향하기
- 타입을 잘 설계하려면 유효한 상태만 표현할 수 있는 타입을 만들어야함
타입 설계가 잘못 된 예시 1
interface State {
pageText: string;
isLoading: boolean;
error?: string;
}
function renderPage(state:State) {
if(state.error) {
return `Error`;
} else if(state.isLoading) {
return 'loading currentPage';
}
return 'currentPage'
}
isLoading이 true이고 동시에 error 값이 존재한다면 로딩 중인 상태인지, 오류가 발생한 상태인지 명확하게 구분이 힘듬.
async function changePage(state: State, 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;
}
}
문제점
- try문 내에 에러가 발생했을 경우 state.isLoading을 false하는 부분이 없음
- state.error를 초기화 하지 않았기 때문에, 페이지 전환중에 과거 로딩 에러 메세지를 보기 있게 됨
수정한 타입 설계 예시
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};
}
pending, error, success 네트워크 요청 상태를 태그된 유니온방식으로 사용함.
장점
- 무효한 상태를 허용하지 않음
단점
- 코드의 길이가 길어짐
수정된 타입설계를 바탕으로 수정한 함수
function 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 '<hl>${currentPage}</hl>\n${requeststate.pageText}';
}
}
async function changePage(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};
}
}
- requeststate에 대한 모호함이 사라짐
타입 설계가 잘못 된 예시 2
기장과 부기장이 조종하는 스틱
interface CockpitControlers {
leftSideStick: number;
rightSideStick: number;
}
스틱의 설정을 계산하는 함수
왼쪽 스틱이 중립이면 오른쪽 스틱을 이용하고, 오른쪽 스틱이 중립이면 왼쪽 스틱을 이용해야한다함.
function getStickSetting(controls: CockpitControls) {
const {leftSideStick, rightSideStick} = controls;
if (leftSideStick === 0) {
return rightSideStick;
}
return leftSideStick;
}
두 스틱이 모두 중립이 아닌 경우에는 ?
두 스틱이 비슷한 값이면 스틱의 각도를 평균해서 계산한다함
function getStickSetting(controts: CockpitControls) {
const {leftSideStick, rightSideStick} = controls;
if (leftSideStick === 0) {
return rightSideStick;
} else if (rightSideStick === 0) {
return leftSideStick;
}
if (Math.abs(leftSideStick - rightSideStick) < 5) {
return (leftSideStick + rightSideStick) / 2;
}
// ???
}
스틱의 각도가 큰 경우에는 해결이 어렵다함.
기장은 스틱을 앞으로 당기고있고, 부기장은 스틱을 뒤로 당기고 있다면 ?
function getStickSetting(controls: CockpitControls) {
return (controls.leftSideStick + controls.rightSideStick) / 2;
}
평균값에는 변함이 없었고 비행기는 하강하지 않아서 추락했다.
수정된 타입 설계 예시
(가정)
대부분의 비행기는 두 개의 스틱이 기계적으로 연결되어있다.
기장이 뒤로 당기면, 부기장의 스틱도 뒤로 당겨진다.
interface CockpitControls {
/** 스틱의 각도, 0 = 중립, + = 앞으로 **/
stickAngle: number;
}
- 스틱의 설정을 계산하는 함수 getStickSetting 함수가 필요없어짐
요약
- 유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발함
- 유효한 상태만을 표현하는 타입을 지향해야함
- 코드가 길어지고 표현하기 어렵지만, 결국 시간을 절약하고 고통을 줄여줌
아이템 29 사용할 때는 너그럽게, 생성할 때는 엄격하게
- 함수의 매개변수 타입은 범위가 넓어도 되지만, 반환 타입은 범위가 구체적이여야 함
타입 설계가 아쉬운 예시
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
interface CameraOptions {
center?: LngLat;
zoom?: number;
bearing?: number; pitch?: number;
}
type LngLat =
{ lng: number; lat: number; } | { lon: number; lat: number; } |
[number, number];
type LngLatBounds =
{northeast: LngLat, southwest: LngLat} |
[LngLat, LngLat] |
[number, number, number, number];
타입을 사용한 함수 예시
function focusOnFeature(f: Feature) {
const bounds = calculateBoundingBox(f);
const camera = viewportForBounds(bounds);
setCamera(camera);
const {center: {lat, lng}, zoom} = camera;
// ~ ... 형식에 'lat' 속성이 없습니다.
// ~ 형식에 '*Ing 속성이 없습니다.
zoom; // 타입o| number | undefined
window.location.search = ' ?v=@${lat},${lng}z${zoom}';
}
- type LngLat에 {lat, lng} 타입은 없음으로 속성이 없다로 오류남
- zoom도 CameraOption에 zoom?임으로 number | undefined 오류남
- camera의 타입(viewportForBounds의 타입 선언)이 사용될때 뿐만아니라 만들어질때에도 너무 자유로워서 문제임
해결법
- camera 타입을 유니온 타입으로 만듬
- viewportForBounds의 반환 타입을 엄격하게 만듬
- 사용하기 편리한 API일 수록 반환 타입이 엄격함
수정된 타입 설계 예시
interface LngLat { lng: number; lat: number; };
type LngLatLike = LngLat | { lon: number; lat: number; } |
[number, number];
interface Camera {
center: LngLat;
zoom: number;
bearing: number;
pitch: number;
}
interface CameraOptions extends Omit<Partial<Camera>, 'center'> {
center?: LngLatLike; }
type LngLatBounds =
{northeast: LngLatLike, southwest: LngLatLike} |
[LngLatLike, LngLatLike] | [number, number, number, number];
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;
- 엄격하 Camera 타입을 느슨한 CameroOptions 타입으로 변환
- Partial로 Camera 타입을 ? 로 선택적으로 바꾸고, Omit으로 center 타입을 제거한 이후 center?: LngLatLike 타입을 추가함
Omit과 Partial이 복잡해 보인다면 아래와 같이 CameraOptions를 사용해도 됨
interface CameraOptions {
center?: LngLatLike;
zoom?: number;
bearing?: number;
pitch?: number;
}
타입을 사용한 함수 예시
function focusOnFeature(f: Feature) {
const bounds = calculateBoundingBox(f);
const camera = viewportForBounds(bounds); setCamera(camera);
const {center: {lat, lng}, zoom} = camera; // 정상
zoom; // 타입이 number window, location.search = '?v=@${lat};${lng}z${zooni}';
}
- lat, lng가 있음으로 타입 오류가 나지 않음
- camera의 타입의 zoom이 number임으로 number | undefined 오류가 나지 않음
요약
- 선택적 속성과 유니온 타입은 매개변수 타입에서 사용하자
- 반환타입은 최대한 엄격하게 사용하자
- 매개변수와 반환 타입의 재사용을 위해선 기본 형태와 Partial를 이용하여 느슨한 타입을 도입하는 방법도 괜찮다.
아이템 30 문서에 타입 정보를 쓰지 않기
지양해야 하는 예시
/*
매개변수가 있을 때는 특정 페이지의 전경색을 반환합니다.
*/
function getForegroundColor(page?: string) {
return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0}; }
문제점
- 코드의 정보와 주석의 정보가 일치 하지 않음
- 주석에는 전경색을 반환한다고 했지만 코드는 {r,g,b} 객체를 반환함
- 주석이 불필요하게 장황함.
수정한 예시
/** 애플리케이션 또는 특정 페이지의 전경색을 가져옵니다. **/
function getForegroundColor(page?: string): Color {
// ...
}
요약
- 주석과 변수명에 타입 정보를 작성하는 것을 지양하자
- 타입이 명확하지 않는 경우에는 변수명에 단위 정보를 포함하는 것을 고려해보자
- ex) timeMS or temperatureC
아이템 31 타입 주변에 null 값 배치하기
- 변수 B가 변수 A의 값으로 부터 비롯되는 값이라면 A가 null이냐 null이 아니냐에따라 B의 값도 null이 될 수도 있다.
strictNullCheck 비활성화의 경우
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);
}
}
return [min, max];
}
- 타입 체커를 통과함
- 최솟값이나 최대값이 0인 경우 [0,1,2] 의 경우 [0,2]가 아닌 [1,2] 결과가 나옴
strictNullCheck 활성화의 경우
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); // 'number | undefined' 형식의 인수는 'number' 형식의 매개변수에 할당될 수 없다.
}
}
return [min, max];
}
// 사용시
const [min, max] = extent([0,1,2]);
const span = max - min; // ~~개체가 'undefined' 인 것 같습니다
- 함수내에서도 에러가 보임
- 사용시에도 undefined 에러가 보임
수정한 함수
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]);
const span = max - min
- result라는 객체를 만들어서 설계를 개선함
- null 관련된 버그를 제거함
- 사용시에도 타입 에러를 제거함
null과 null 이 아닌 값을 섞여서 사용한 경우 문제가 발생한 예시
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 이 아닌 경우가 등 4가지 경우의 수가 존재함
- null 체크를 하지 않으면 버그가 발생할 수 있음
수정한 코드
class UserPosts {
user: UserInfo;
posts: Post;
constructor(user: UserInfo, posts: Post[]) {
this.user = user;
this.posts = posts;
}
static async init(userId: string) {
return Promise.all([
async() => this.user = await fetchUser(userId),
async() => this.posts = await fetchPostsForUser(userId)
]);
return new UserPosts(user, posts);
}
getUserName() {
return this.user.name;
}
}
- user, posts 의 타입이 null을 허용하지 않음
- 생성자에서 부터 값을 주입해줌 (UserPosts 클래스는 완전히 null 이 아니게 됨)
요약
- 한 값의 null 여부가 다른 값의 null 여부에 관련되도록 설계하면 안됨
- API 작성시에는 반환 타입을 전체가 null이거나, null 이 아니게 설계 해야함
- 클래스를 만들 시에는 필요한 모든 값이 준비되었을 때 생성해야함(null 존재 X)
- null 체크를 위해선 strictNullChecks를 설정해야함
아이템 32 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기
아이템32를 읽기전에 유니온 인터페이스와 인터페이스 유니온의 정의가 너무 헷갈려서 정리를 하려고 한다.
유니온 인터페이스
- 여러 개의 인터페이스를 하나의 새로운 인터페이스로 결합하는 방식
- 여러 인터페이스의 속성을 하나의 인터페이스로 합쳐서 사용할 수 있다.
- 하나의 인터페이스 안에서 값의 타입으로 유니온을 사용하는 것
인터페이스 유니온
- 유니온 타입을 이용하여 인터페이스를 다양한 타입으로 결합하는 방식.
- 인터페이스가 여러 타입을 허용하도록 하는 것
유니온 인터페이스
interface Layer {
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
유니온 인터페이스로 작성을 하게되면 layout이 FillLayout이면서 paint가 LinePaint인 경우가 발생한다.
그렇게이 인터페이스 유니온 혹은 태그된 유니온 으로 작성하는게 좋음
인터페이스 유니온
interface FillLayer {
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
두개 차이점은 알겠는데 용어가 왜이리 헷갈리냐.. 잘 기억할 수 있는 팁 아시면 댓글남겨주세요.
태그된 유니온
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;
type 속성은 '태그' 이며 런타임에서 Layer가 어떤 타입인지 판단하는데 도움이 된다.
여러개의 선택적 필드가 있는 경우 예시
birth내 place, date가 둘 다 없거나 둘 다 있을 경우
지양 하는 방식
interface Person {
name: string;
placeOfBirth?: string;
dateOfBirth?: Date;
}
지향 하는 방식
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;
function test(p : Person) {
if('placeOfBirth' in p) {
p
const {dateOfBirth} = p
}
}
요약
- 유니온 타입의 속성을 여러 개 가지는 인터페이스는 속성 간의 관계가 분명하지 않기 때문에 실수가 발생할 수 있다.
- 유니온 인터페이스보단 인터페이스 유니온이 더 정확하고 타입스크립트가 이해하기 좋다.
- 타입스크립트가 제어 흐름을 분석할 수 있도록 태그를 넣는 것을 고려해보자. 자주 사용하는 패턴이다.(예시 Layer)
아이템 33 string 타입보다 더 구체적인 타입 사용하기
지양하는 방식
interface Album {
artist: string;
title: string;
releaseDate: string;
recodingType: string; // live or studio
}
const kindOfBlut: Album = {
artist: 'Miles Davis',
title: 'Kind of Blue',
releaseDate: 'August 17th',
recodingType: 'Studio'
}
- string의 타입범위는 매우 넓기 때문에, recodingType 'studio'를 예상했지만 'Studio'가 들어와도 방어하지 못한다.
- 매개변수의 순서가 잘못된 경우에도 잡아낼 수가 없다.
지향하는 방식
interface Album {
artist: string;
title: string;
releaseDate: Date;
recodingType: RecodingType; // live or studio
}
const kindOfBlut: Album = {
artist: 'Miles Davis',
title: 'Kind of Blue',
releaseDate: new Date('1995-08-17')
recodingType: 'Studio' //Type '"Studio"' is not assignable to type 'RecodingType'. Did you mean '"studio"'?
}
타입을 명시적으로 정의함으로써 이점
- 다른 곳으로 값이 전달되어도 타입 정보가 유지됨
- 타입의 의미를 설명하는 주석을 넣을 수 있음
- keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능함
keyof 연산자 사용 예시
// 지양해야 하는 방식
function pluck(records: any[], key: string): any[] {
return records.map(r => r[key]);
}
// 타입 에러 발생
function pluck<T>(records: T[], key: string) : any[]{
return records.map(r => r[key]); // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'unknown'.
}
// keyof를 이용한 해결
function pluck<T>(records: T[], key: keyof T) {
return records.map(r => r[key]);
}
허나 key값으로 releaseDate를 넣었을 경우 Date[]가 아닌, (string | Date)[]로 타입의 범위가 넓게 나온다.
조금 더 타입을 좁히기 위해 extneds를 사용한다.
function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
return records.map(r => r[key]);
}
타입 시그니처가 완벽해졌다.
요약
- string 타입을 남발하지 말자
- 변수의 범위를 정확하게 표현하고 싶다면 string 보다는 문자열 리터럴 타입을 이용하자.
- 객체의 속성을 받을 경우에는 string 보단 keyof를 이용하자
아이템 34 부정확한 타입보다는 미완성 타입을 사용하기
보통 타입 설계를 구체적으로 할수록, 버그를 많이 잡을 수 있다.
허나 실수가 발생하기 쉽고 잘못된 타입은 차라리 타입이 없는 것보다 못할 수 있다.
지양해야 하는 방식
type Expression1 = any;
type Expression2 = number | string | any[];
const tests: Expression2[] = [
10,
"red",
true, // 타입 에러 발생
["+", 10, 5],
["case", [">", 20, 10], "red", "blue"],
["**", 2, 31],
["rgb", 255, 0, 127, 0]
]
타입에러가 발생하여 아래에 좀 더 구체적인 타입을 추가함
interface MathCall {
0: '+' | '-' | '/' | '*';
1: Expression4;
2: Expression4;
}
interface CaseCall {
0: 'case';
1: Expression4;
2: Expression4;
3: Expression4;
}
interface RGBCall {
0: 'rgb';
1: Expression4;
2: Expression4;
3: Expression4;
}
type Expression4 = number | string | CallExpression;
type CallExpression = MathCall | CaseCall | RGBCall;
타입의 정보는 정밀해졌지만 이전 버전보다 개선되었다고 보기가 어려움.
오류 메세지는 더 난해해짐.
어설프게 타입을 선언하다간 오히려 역효과가 발생함
요약
- 타입 선언을 정확하게 할 수 없다면 하지 말아야함.
- 타입 정보를 구체적으로 설계할 수록 오류 메세지와 자동 완성 기능에 주의를 기울여야함
아이템 35 데이터가 아닌, API와 명세를 보고 타입 만들기
타입은 잘 설계하면 이점이 많지만, 잘못된 설계는 오히려 악효과를 낼 수 있다. 이로인해 타입을 자동으로 생성한다면 유용할 것이다.
가능한 파일 형식, API, 명세 등 데이터를 보고 판단하는 것이 아닌 명세서를 보고 타입을 생성해야한다.
- GraphQL 쿼리를 타입스크립트 타입으로 변환해주는 도구를 사용할 수도 있다. (ex Apollo)
- DOM API (https://www.typescriptlang.org/docs/handbook/dom-manipulation.html)
아이템 36 해당 분야의 용어로 타입 이름 짓기
타입의 이름을 명확하게 지어야 코드의 의도를 파악하기 쉽다.
지양 해야하는 방식
interface Animal {
name: string;
endangered: boolean;
habitat: string;
}
const leopard: Animal = {
name: 'Snow Leopard',
endangered: false,
habitat: 'tundra'
}
- name이 동물의 학명인지 명칭인지 불분명함
- endangered속성이 멸종위기를 표현하기 위한 것인데 멸종된 동물이 true인지 false인지 파악이 어려움
- habitat 서식지가 string으로 넓은 타입임
지향 해야하는 방식
interface Animal {
commonName: string;
genus: string;
species: string;
status: ConservationStatus;
climates: KoppenClimate[];
}
type ConservationStatus = 'EX' | 'EW' | 'VU'
type KoppenClimate = 'Af' | 'Am' | 'As' | 'EF'
const snowLeopard: Animal = {
commonName: 'Snow Leopard',
genus: 'Panthera',
species: 'Uncia',
status: 'VU',
climates: ['Af', 'EF']
}
- name을 commonName, genus, species로 좀 더 구체적인 용어로 대체함
- endangered는 보호등급에 대한 체제인 ConservationStatus로 변경
- habitat은 기후를 의미하는 climates로 변경
- 코드로 표현하고자 하는 분야의 용어를 내가 만들지말고 오래전부터 존재해왔던 전문적인 용어를 이용하자.
- data, info, thing, item, object, entity같은 모호한 이름은 피하자(찔리네)
- 이름을 지을때는 포함된 내용이나 계산방식보다는 데이터 자체가 무엇인지 고려해야함
아이템 37 공식 명칭에는 상표를 붙이기
구조적 타이핑으로 인해 가끔 코드가 의도치 않은 결과를 낼 수 있다.
예시
interface Vector2D {
x: number;
y: number;
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
calculateNorm({x:3, y:4}); // 정상, 5를 반환
const vec3D = {x:3, y:4, z:1}; // 정상! 결과는 동일하게 5
calculateNorm(vec3D);
vec3D를 살펴보면 구조적 타이핑 관점에서는 문제가 없지만, 2차원 백터를 사용해야 하는 것이 맞다.
3차원 백터를 허용하지 않게 하려면 상표(brand)를 붙인다.
수정한 예시
interface Vector2D {
_brand: '2d';
x: number;
y: number;
}
function v2c2D(x: number, y: number): Vector2D {
return {x,y, _brand: '2d'};
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
calculateNorm({x:3, y:4}); // 정상, 5를 반환
const vec3D = {x:3, y:4, z:1};
calculateNorm(vec3D); // '_brand' 속성이 없습니다
단순한 실수를 방지할 수 있음.
절대 경로를 이용한 예시를 추가로 들고있는데 상표 기법을 실제로 많이 쓰는지 모르겠음