본문 바로가기
패스트캠퍼스/자습

이펙티브 타입스크립트 정리 - 4장

by sunnykim91 2023. 6. 9.

아이템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 `CURRENT Page`;
}
  • 조건이 명확히 분리되어 있지 않음
  • 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("Error");
        }
        const text = await response.text();
        state.isLoading = false;
        state.pageText = text;
    } catch (e) {
        state.error = "" + e;
    }
}
  • 문제점
  1. 오류가 발생했을 때 state.isLoading을 false로 설정하는 로직이 빠져있음
  2. state.error를 초기화하지 않아 페이지 전환 중에 로딩 메시지 대신 과거 오류 메시지를 보여주게 됨
  3. 페이지 로딩 중 사용자가 페이지 변경시 일어나는 일에 대한 예상이 어려움, 새 페이지에 오류가 뜨거나, 응답이 오는 순서에 따라 두번째 페이지가 아닌 첫번째 페이지로 전환될 가능성이 있음

문제점을 개선한 코드

interface RequestPending {
    state: "pending";
}

interface RequestError {
    state: "pending";
    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 "currentPage";
    }
}

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("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 =
    | { northeast: LngLat; southwest: LngLat }
    | [LngLat, LngLat]
    | [number, number, number, number];

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

function focusOnFeature(f: Feature) {
    const bounds = calculateBoundgbox(f);
    const camera = viewportForBounds(bounds);
    setCamera(camera);
    const {
        center: { lat, lng },
        zoom,
    } = camera; // 에러 발생 , Property 'lat', 'lng' does not exist on type 'LngLat | undefined'.
    zoom; // 타입이 number | undefined
    window.location.search = "test";
}

viewportForBounds의 타입 선언이 사용될 때 뿐만 아니라 만들어질 때 너무 자유로운것이 문제

매개변수 타입의 범위가 넓으면 사용하기 편리하지만, 반환 타입의 범위가 넓으면 불편하다.

수정 된 코드

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;
}
// 다른 방법
interface CameraOptions {
    center?: LngLatLike;
    zoom?: number;
    bearing?: number;
    pitch?: number;
}

type LngLatBounds =
    | { northeast: LngLatLike; southwest: LngLatLike }
    | [LngLatLike, LngLatLike]
    | [number, number, number, number];

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

Camera가 너무 엄격하므로 조건을 완화하여 느슨한 CameraOptions타입으로 만듬
focusOnFeature함수에서도 기존 에러가 나지 않음

선택적 속성과 유니온 타입은 반환타입보단 매개변수 타입에 더 일반적이다.

아이템30. 문서에 타입 정보를 쓰지 않기

주석과 변수명에 타입 정보를 적는 것을 피하자.

// 전경색 문자열을 반환한다.
// 0개 또는 1개의 매개변수를 받는다.
// 매개변수가 없을 때느 표준 전경색을 반환한다.
// 매개변수가 있을 때는 특정 페이지의 전경색을 반환한다.

function getForegroundColor(page?: string) {
    return page === "login" ? { r: 127, g: 127, b: 127 } : { r: 0, g: 0, b: 0 };
}

위 주석의 내용의 문제점

  • 함수가 string을 반환한다고 하지만, 실제론 객체를 반환한다.
  • 주석에 0개 또는 1개의 매개변수를 받는다고 하는데 타입 시그니처만 봐도 알 수 있는 정보이다.
  • 불필요하게 장황하다.

다음과 같이 개선해볼 수 있다.

// 어플리케이션 또는 특정 페이지의 전경색을 가져옵니다.
function getForegroundColor(page?: string): Color {
    return page === "login" ? { r: 127, g: 127, b: 127 } : { r: 0, g: 0, b: 0 };
}

타입이 명확하지 않은 경우는 변수명에 단위정보를 포함하는 것도 고려하자.
ex) timeMs 또는 temperatureC

아이템31. 타입 주변에 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); // 오류 발생 , Argument of type 'number | undefined' is not assignable to parameter of type 'number'. Type 'undefined' is not assignable to type 'number'.
        }
    }
    return [min, max];
}

위 코드의 문제점.
min의 경우만 제외하고 max에서는 제외를 하지 않아 에러 발생

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 로 하여 오류가 발생하지 않도록함

또 하나의 예제

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 fetchPost(userId)),
        ]);
    }

    getUserName() {
        // ...
    }
}

user와 posts속성은 api요청시 로드되는동안 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),
            fetchPost(userId),
        ]);
        return new UserPosts(user, posts);
    }

    getUserName() {
        return this.user.name;
    }
}

이 클래스는 null이 아니게 되었고, 메서드를 작성하기 쉬워진 상태가 되었음.
*null인 경우가 필요한 속성은 프로미스로 바꾸면 안된다.

API 작성 시에는 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야한다.
클래스를 만들때는 null이 존재하지 않도록 하는 것이 좋다.

아이템32. 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

interface FillLayer {
    type: "fill";
    layout: FillLayout;
}

interface LineLayer {
    type: "line";
    layout: LineLayout;
}

interface PointLayer {
    type: "point";
    layout: PointLayout;
}
type Layer = FillLayer | LineLayer | PointLayer;

type속성은 태그 이며 런타임 때 어떤 타입의 Layer가 사용되는지 판단하는데 쓰이며, 타입스크립트는 이 태그를 참고하여 Layer의 타입의 범위를 좁힐수도있습니다.

두객체의 속성을 하나의 객체로 모으는 것이 더 낫다.

interface Person {
    name: string;
    placeOfBirth?: string;
    dateOfBirth?: Date;
}

// 위 코드 보단 아래코드로
interface Person {
    name: string;
    birth?: {
        place: string;
        date: Date;
    };
}

인터페이스의 유니온을 사용하여 속성 사이의 관계를 모델링하기.
아래와같이 PersonWithBirth는 Name을 확장하여 사용할수도 있다.

interface Name {
    name: string;
}

interface PersonWithBirth extends Name {
    placeOfBirth: string;
    dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

아이템33. string타입보다 더 구체적인 타입 사용하기

string을 남발하지 않기

interface Album {
    artist: string;
    title: string;
    releaseDate: string;
    recordingType: string;
}

// 타입을 명확하게 표시하는게 좋다. 날짜는 날짜로, 타입은 타입별로

type RecordingType = "studio" | "live";

interface Album {
    artist: string;
    title: string;
    releaseDate: Date;
    recordingType: RecordingType;
}

위와 같이 코딩했을때 얻을 수 있는 장점

  1. 타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입이 유지된다.
  2. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있다.
  3. keyof연산자로 더욱 세밀하게 객체의 속성 체크가 가능해진다.
function pluck<T>(records: T[], key: keyof T): any[] {
    return records.map((r) => r[key]);
}

매개변수의 타입을 string을 선언할때 보다 keyof 를 활용함으로써 타입스크립트가 반환타입을 추론할 수 있게 되고, 에러도 줄일 수 있다.

아이템34. 부정확한 타입보다는 미완성 타입을 사용하기

타입을 정제 할 때는 any같은 타입은 정제하는 것이 보통 맞다.

하지만, 타입이 구체적으로 정제된다고 해서 정확도가 무조건 올라간다고 보면 안된다.

타입이 없는것 보단 타입이 잘못된게 더 나쁘다.

정확하게 타입을 모델링할 수 없다면, 부정확하게 모딜링하지 말아야한다.

아이템35. 데이터가 아닌, API와 명세를 보고 타입 만들기

API나 doc문서든 명세된 내용을 기반으로 타입을 작성하는 것이 좋다.
특히 GraphQL API는 타입스크립트외 비슷한 타입 시스템을 상요하여 가능한 모든 쿼리와 인터페이스를 명시하는 스키마로 이루어진다.

  • Appllo: GraphQL쿼리를 타입스크리브 타입으로 변환해주는 도구

데이터보다나는 명세로부터 코드를 생성하는 것이 좋은 방법이다.

아이템36. 해당 분야의 용어로 타입 이름 짓기

변수 네이밍을 할때는 구체적인 용어로, 명확하게 표현할 수 있고, 전문용어가 있다면 그것을 사용하기

3가지 규칙

  1. 동일한 의미를 나타낼때는 같은 용어 사용하기.
  2. data, info, thing, item, object, entity 같은 모호하기 의미없는이름은 피하기
  3. 데이터 자체가 무엇인지 고려하기. INodeLIst보다는 Directory가 더 의미 있는 이름.

아이템37. 공식 명칭에는 상표를 붙이기

상표기법 - 스타벅스가 아니라 커피 라고 표현하기

예시

type SortedList<T> = T[] & { _brand: "sorted" };

type Meters = number & { _brand: "meters" };
type Seconds = number & { _brand: "seconds" };

값을 세밀하기 구분하기 위하여 공식명칭이 필요하다면 상표를 고려하기

반응형