효과적으로 타입을 설계하려면, 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요합니다.
⭐️ 분기 조건이 명확히 분리되어 있는 않은 코드(필요한 정보가 부족)
// 페이지의 상태
interface State {
pageText: string;
insLoading: boolean;
error?: string
}
// 페이지를 그리는 함수
function renderPage(state: State) {
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}`;
}
// 페이지를 전환하는 함수
async function changePage(state: State, newPage: string) {
state.isLoading = true;
try {
const response = await fetch(getUrlForPage(newPage));
if (!repsonse.ok) {
throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
}
const text = await reponse.text();
state.isLoading = false;
state.pageText = text;
} catch (e) {
state.error = '' + e;
}
}
상태 값의 두 가지 속성이 동시에 정보가 부족하거나, 두 가지 속성이 충돌 할 수 있다는 것입니다. 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 ${currentPAge}...`;
case 'error':
return `Error! Unable to load ${currentPage}: ${requestState.error}`;
case 'ok':
return `<h1>${currentPage}</h1>\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 (!repsonse.ok) {
throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
}
const pageText = await reponse.text();
state.requests[newPage] = {state: 'ok', pageText};
} catch (e) {
state.requests[newPage] = {state: 'error', error: '' + e};
}
}
요약
📌 유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발하게 됩니다.
📌 유효한 상태만 표현하는 타입을 지향해야 합니다. 코드가 길어지거나 표현하기 어렵지만 결국은 시간을 절약하고 고통을 줄일 수 있습니다.
함수의 매개변수는 타입의 범위가 넓어도 되지만, 결과를 반환할 때는 일반적으로 타입의 범위가 더 구체적이어야 합니다.
그렇다고 범위를 너무 넓게 설정할 경우 undefined가 포함되어 추론되는 경우가 있습니다.
이를 방지하기 위해서는 타입 선언시에는 명시적으로 엄격하게 선언하고,
타입을 조건 완화 조합하여 느슨한 타입을 만들어 매개변수에 사용하는 것이 좋습니다.
반대로 타입을 반환할 때에는 반드시 엄격한 타입을 반화해야 사용성이 좋아집니다.
매개변수 타입의 범위가 넓으면 사용하기 편리하지만, 반환 타입의 범위가 넓으면 불편합니다.
즉, 사용하기 편리한 API일수록 반환 타입이 엄격합니다.
interface CameraOptions {
center?: LngLat;
zoom?: number;
bearing?: number;
pitch?: number;
}
type LngLat = { lng: number; lat: number } | {lon: number; lat: number;} | [number, number];
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LonLatBounds): CameraOptions;
type LngLatBounds = { northeast: LngLat; southwest: LngLat; } | [ LngLat, LngLat ] | [ number, number, number, number ]
// LngLat는 세 가지 형태를 받을 수 있기 때문에, LngLatBounds의 가능한 형태는 19가지 이상으로 매우 자유로운 타입입니다. (나쁜 설계)
function focusOnFeature(f: Feature) {
const bounds = calculateBoundingBox(f);
const camera = viewportForBounds(bounds);
setCamera(camera);
const {center: {lat, lng}, zoom} = camera;
// ~~~~~ ...형식에 'lat'속성이 없습니다.
// ~~~ ...형식에 'lng'속성이 없습니다.
zoom; // 타입이 number | undefined
window.loaction.search = `?v=@${lat},${lng}z${zoom}`;
}
위 예제의 오류는 lat과 lng 속성이 없고 zoom 속성만 존재하기 때문에 발생했지만, 타입이 number | undefined로 추론되는 것 역시 문제입니다.
camera 값을 안전한 타입으로 사용하는 유일한 방법은 유니온 타입의 각 요소별로 코드를 분기하는 것입니다.
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 extend Omit<Partial<Camera>, 'center'> {
center?: LngLatLike;
}
type LngLatBounds =
{northeast: LngLatLike, soutwest: LngLatLike} |
[LngLatLike, LngLatLike] |
[number, number, number, number];
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;
Camera가 너무 엄격하므로 조건을 완화하여 느슨한 CameraOptions 타입으로 만들었습니다.
너무 복잡해 보인다면 약간의 반복 작업을 해야겠지만 명시적으로 타입을 추출해서 다음처럼 작성할 수도 있습니다.
interface CameraOptions {
center?: LngLatLike;
zoom?: number;
bearing?: number;
pitch?: number;
}
앞에서 설명한 CameraOptins를 선언하는 두가지 방식 모두 focusOnFeature함수가 타입 체커를 통화할 수 있게 합니다.
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${zoom}`;
}
요약
📌 보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있습니다. 선택적 속성과 유니온 타입은 반환 타입보다 매개변수 타입에 더 일반적입니다.
📌 매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)를 도입하는 것이 좋습니다.
⭐️ 코드와 주석이 맞지 않는다면, 둘 다 잘못된 것입니다.
타입스크립트의 타입 구문 시스템은 간결하고, 구체적이며, 쉽게 읽을 수 있도록 설계되었습니다.
즉, 함수의 입력과 출력의 타입을 코드로 표현하는 것이 주석보다 더 나은 방법이라는 것입니다.
타입 구문은 타입스크립트 컴파일러가 체크해 주기 때문에, 절대로 구현체와의 정합성이 어긋나지 않습니다.
누군가 강제하지 않는 이상 주석은 코드와 동기화 되지 않습니다. 그러나 타입 구문은 타입스크립트 타입 체커가 타입 정보를 동기화하도록 강제합니다.
⭐️ 값을 변경하지 않는다고 설명하는 주석, 또는 매개변수를 변경하지 않는다는 주석은 사용하지 않는 것이 좋습니다.
/** nums를 변경하지 않습니다. **/
function sort(nums: number[]) {/* ... */}
위에 코드 보다는 readonly로 선언하여 규칙을 강제할 수 있게 하면 됩니다.
function sort(nums: readonly number[]) {/* ... */}
⭐️ 변수명에 타입 정보를 넣지 않도록 합니다.
// 변수명이 ageNum 보다는 age로 하고, 타입을 number임을 명시하는 게 좋습니다.
const ageNum = 28;
const age: number = 28;
단, 단위가 있는 숫자들은 예외입니다.(time 보다는 timeMS 등...)
요약
📌 주석과 변수명에 타입 정보를 적는 것은 피해야 합니다.
📌 타입이 명확하지 않은 경우는 변수명에 단위 정보를 포함하는 것을 고려하는 것이 좋습니다.
strictNullChecks 설정을 처음 켜면, null이나 undefined 값 관련된 오류들이 갑자기 나타납니다.
어떤 변수가 null이 될 수 있는지 없는지를 타입만으로는 명확하게 표현하기가 어렵습니다.
값이 전부 null 이거나 전부 null이 아닌 경우로 분명히 구분된다면, 값이 섞여 있을 때보다 다루기 쉽습니다.
// 타입 체커를 통과하고, 반환 타입은 number[]로 추론됩니다.
// 그러나 버그와 함께 설계적 결함이 있습니다.
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];
}
⭐️ 더 나은 해법(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;
}
반환 타입이 [number, number] | 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),
anync () => this.posts = await fetchPostsForUser(userId)
]);
}
getUserName() {
// ...?
}
}
// 두번의 네트워크 요청이 로드되는 동안 user와 posts속성은 null 상태.
// 어떤 시점에서는 둘다 null이거나, 둘중 하나만 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 여부에 암시적으로 관련되도록 설계하면 안됩니다.
📌 API 작성 시에는 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야 합니다. 사람과 타입 체커 모두에게 명료한 코드가 될 것입니다.
📌 클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하는 것이 좋습니다.
📌 strictNullChecks를 설정하면 코드에 많은 오류가 표시되겠지만, null 값과 관련된 문제점을 찾아낼 수 있기 때문에 반드시 필요합니다.
유니온 타입의 속성을 가지는 인터페이스를 작성 중이면, 인터페이스의 유니온 타입을 사용하는게 더 알맞지는 않을지 검토해 봐야 합니다.
interface Layer {
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
// 더 나은 방법으로 모델링 할 경우 각각 타입의 계층을 분리된 인터페이스로 두면 됩니다.
interface FillLayer {
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer; // 유효한 상태만을 표현하도록 타입을 정의
이러한 패턴의 가장 일반적인 예시는 태그된 유니온(또는 구분된 유니온)입니다.
interface Layer {
type: 'fill' | 'line' | 'point';
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
// type 과 layout에 타입이 다르게 쓰이는 것은 말이 되지 않습니다.
// 이러한 경우를 방지하기 위해 인터페이스의 유니온으로 변환.
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의 타입의 범위를 좁힐 수도 있습니다.
function 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
}
}
선택적 필드가 동시에 값이 있거나 동시에 undefined인 경우 태그된 유니온 패턴이 잘 맞습니다.
interface Person {
name: string;
// 다음은 둘 다 동시에 있거나 동시에 없습니다.
placeOfBirth?: string;
dateOfBirth?: Date;
}
// 타입 정보를 담고 있는 주석은 문제가 될 소지가 높습니다.
// 두 개의 속성을 하나의 객체로 모으는 것이 더 나은 설계입니다.
interface Person {
name: string;
birth?: {
place: string;
date: Date;
}
}
const user: Person = {
name: 'lee JS',
birth: {
place: 'London'
// 'date' 속성이 '{place: string;}' 형식에 없지만
// '{place: string; date: Date;}' 형식에서 필수입니다.
}
}
요약
📌 유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 실수가 발생하지 않도록 주의해야 합니다.
📌 유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 타입스크립트가 이해하기도 좋습니다.
📌 타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그를 넣는 것을 고려해야 합니다. 태그된 유니온은 타입스크립트와 매우 잘 맞기 때문에 자주 볼 수 있는 패턴입니다.