[이펙티브 타입스크립트] 4장 타입 설계

JIY00N·2023년 8월 14일
0

TypeScript

목록 보기
5/9
post-thumbnail
post-custom-banner

2023.08.14~17 아이템28~37

4장 타입 설계

타입 설계를 이해하고 타입을 제대로 작성한다면,
테이블(코드의 타입)순서도(코드의 로직)쉽게 이해할 수 있게 된다.

아이템 28 유효한 상태만 표현하는 타입을 지향하기


  • 효과적으로 타입을 설계하려면, 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요하다.

  • 상태를 표현하는 타입을 만들 때는 유효한 타입만을 가지고 있어야 한다.

    예시
    State 타입에는 a와 b 속성은 반드시 있고, c와 d 속성은 선택적인 속성이다.
    그리고 State 타입은 3가지 상태가 있다고 가정(One(a,b) / Two(a,b,c) / Three(a,b,d))

  1. 지양해야 할 방식 (속성에 중점을 둔 타입 정의)
    -> 이 방법은 One / Two / Three 상태가 아닌 다른 상태(무효한 상태)도 허용한다.
interface State{
  a: string;
  b: string;
  c?: string;
  d?: string;
}
  1. 지향해야 할 방식 (상태에 중점을 둔 타입 정의)
    -> 코드는 길어지지만, 무효한 상태를 허용하지 않는다.
interface StateOne{
  a: string;
  b: string;
}

interface StateTwo{
  a: string;
  b: string;
  c: string;
}

interface StateThree{
  a: string;
  b: string;
  d: string;
}

type State = StateOne | StateTwo | StateThree;

🎯 요약

유효한 상태만 표현하는 타입을 지향하자

아이템 29 사용할 때는 너그럽게, 생성할 떄는 엄격하게


  • 보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있다.
    -> 선택적 속성과 유니온 타입은 매개변수 타입에서 사용하자.

  • 반환 타입은 기본 형태로, 매개변수 타입은 느슨한 형태로 만들자.

// viewportForBounds: A함수, setCamera: B함수
// A라는 함수의 반환 값이 B라는 함수의 매개변수로써 바로 삽입될 때,
// A의 반환 값의 타입은 엄격하고, B 함수의 매개변수는 유연하게 하자
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 //B
declare function viewportForBounds(bounds: LngLatBounds): Camera //A

🎯 요약

매개변수와 반환 타입의 재사용을 위해 기본 형태와 느슨한 형태를 도입하자

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


  • 특정 매개변수를 설명하고 싶다면 JSDoc의 @param 구문 사용

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

// 1. 주석에 타입 정보 지양
// bad
/** nums를 변경하지 않습니다. */
function sort(nums: number[]){ /*...*/}

// good
function sort(nums: readonly number[]){ /*...*/}

// 2. 변수명에 타입 정보 지양
// bad
const ageNum = 30;

// good
const age = 30;
  • 단위가 있는 숫자들은 예외
// bad
1. time
2. temperature

// good
1. timeMs
2. temperatureC

🎯 요약

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

아이템 31 타입 주변에 null 값 배치하기


  • strictNullChecks를 꼭 사용하자
    -> null이나 undefined 등 관련된 문제점을 찾아낼 수 있다.

  • 값이 전부 null 이거나 아니거나로 구분하자
    -> 타입에 null을 추가하는 방식으로 모델링 가능

// 1. null 아님 단언(!)을 사용 하여 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
}
const [min, max] = extent([0, 1, 2])!
// min, max는 number 타입
const span = max - min // number 타입 ok

// 2. if문 체크
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 range = extent([0, 1, 2])
if (range) {
  const [min, max] = range // range: [number, number]
  const span = max - min // number 타입 ok
}
  • API 작성 시에는 반환 타입을 큰 객체로 만들고, 반환 타입 전체가 null이거나 null이 아니게 만들어야 한다.
interface ApiResponse<T> {
  data: T | null;
  message: string;
}
  • 클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null이 존재하지 않도록 하자
    -> 속성값의 불확실성이 클래스의 모든 메서드에 나쁜 영향을 미친다.
// bad
// 두 번의 네트워크 요청이 로드되는 동안 user와 posts 속성은 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(){
    // ... ?
  }
}

// good
// 필요한 데이터가 모두 준비된 후에 클래스를 만들자
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 UwerPosts(user, posts);
  }
  getUserName(){
    return this.user.name;
  }
}

// null인 경우가 필요한 속성은 프로미스로 바꾸면 안된다.
// gpt왈
// -> 프로미스 그 자체로 null 값을 반환하지 않음(resolve or reject 반환)
// -> 반환 타입이 전체가 null인 경우에는 직접 객체를 사용하거나 유니온 타입 사용

🎯 요약

전부 null이거나 전부 null이 아니거나

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


  • 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하자

  • 태그된 유니온은 타입스크립트 타입 체커와 잘 맞고, 자주 등장하니 패턴을 기억하자.

  • 태그된 유니온으로 표현할 수 있다면, 하는 것이 좋다.

  1. 각 필드가 연관되어 특정될 경우

    ex) LineLayout은 LinePaint가 필요

// 유니온의 인터페이스
interface Layer{
  type: 'fill' | 'line' | 'point';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

// 인터페이스의 유니온
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?: string;
}

// 두 개의 속성을 하나의 객체로 모으자
// birth를 마치 태그처럼 사용
interface Person{
  name: string;
  birth?: {
    place: string;
    date: Date;
  }
}

// ??? 객체의 구조를 바꾸고 싶은데, 손댈 수 없을 때
// 어떤게?
// -> 인터페이스의 유니온을 사용
interface Name{
  name: string;
}
interface PersonWithBirth extends Name{
  placeOfBirth: string;
  dateOfBirth: Date;
}
type Person = Name | PersonWithBirth;

🎯 요약

인터페이스의 유니온을 사용하고, 태그된 유니온 패턴을 익히자

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


  • 문자열을 납발하여 선언된 코드를 피하자
    -> string 타입보단 구체화된 타입을 사용하자
// string 타입 남발
// bad
interface Album{
  artist: string;
  title: string;
  releaseDate: string;  // YYYY-MM-DD
  recordingType: string; // E.g., "live" or "studio"
}

// Date객체와 유니온 타입으로 타입의 범위 좁히기
// good
type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}
  • 이러한 방식의 장점
    1. 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지됨
type RecordingType = 'studio' | 'live';

function getAlbumsOfType(recordingType: string): Album[] {
  // ...
}
/* getAlbumsOfType에서는 아무런 타입정보를 확인할 수 없지만 RecordingType을 통해 정의를 확인할 수 있다. */

2. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있다.

/**  이 녹음이 어떤 환경에서 이루어 졌는지 확인하는 타입 */
type RecordingType = 'live' | 'studio';

3. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능하다.

function pluck(records: any[], key: string): any[] {
  return records.map(r => r[key]);
}
/* any 타입이 있어서 정밀하지 못함. 
특히나 return 타입이 any가 사용되어 좋지 않은 설계 */

// 1. 제너릭 타입 도입 -> key의 범위 string으로 너무 넓어 오류가 발생
function pluck<T>(records: T[], key: string): any[] {
  return records.map(r => r[key]);
                      // ~~~~~~ '{}' 형식에 인덱스 시그니처가 없으므로
                      //       요소에 암시적으로 'any' 형식이 있습니다.
}

// 2. key는 "artist" | "title" | "releaseDate" | "recordingType" 만 유효해야 함

type RecordingType = 'studio' | 'live'

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

type K = keyof Album; // 타입이 "artist" | "title" | "releaseDate" | "recordingType"

// key의 타입을 string -> keyof T로 변경
function pluck<T>(records: T[], key: keyof T) {
  return record.map(r => r[key]);
}

declare let albums: Album[];
const releaseDates = pluck(albums, 'releaseDate'); // 타입이 (string | Date)[]
//releaseDates  Date[]여야 함

// 2. keyof T의 부분 집합으로 두번 째 제너릭 도입
function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
  return record.map(r => r[key]);
}
// 타입 시그니처 완벽

🎯 요약

string 타입보다는 구체적인 타입을 사용하자

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


  • 타입을 구체적으로 정의한다고 해서 정확도가 무조건 올라가지 않는다.

  • 언어 서비스는 타입스크립트 경험에서 중요한 부분이므로, 타입선언으로 인한 오류 메시지를 살펴보고 타입 선언이 동작해야 하는 곳에는 자동 완성을 적용하자.

  • anyunknown을 구별해서 사용해야 한다.

    1. any

    • 모든 타입을 허용한다.
    • TypeScript에서 타입 검사를 느슨하게 하므로 개발 당시에는 문제가 없으나 애플리케이션 또는 웹 페이지 개발 후 예기치 못한 문제가 발생할 가능성이 매우 높다.

      2. unknown
    • 모든 타입을 허용한다.
    • any 타입과는 다르게 프로퍼티 또는 연산하는 경우 컴파일러가 체크한다. 그러므로 문제 되는 코드를 미리 예방할 수 있다.

🎯 요약

타입을 구체적으로 정의할 때, 부정확한 타입이 되지 않도록 주의하자

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


  • 파일 형식, API, 명세 등은 프로젝트 외부에서 비롯된 것이다.
    -> 이러한 경우는 타입을 직접 작성하지 않고 자동으로 생성할 수 있다.

  • 명세를 참고해 타입을 생성하자

  1. Definitely Typed 에 저장된 타입은 다운받아 사용 가능하다.

  2. Graphql Code Generator 타입스크립트와 비슷한 타입 시스템을 사용하는 GraphQL API는 쿼리를 타입스크립트 타입으로 자동 추출해주는 도구를 사용하는것이 좋다.

  3. quickType 같은 도구로 데이터로부터 타입을 생성할 수 있지만 완벽하게 일치하지 않을 수 있다.

  4. 브라우저 DOM API에 대한 타입 선언은 IDE에 포함되어 있기 때문에 잘 사용하자.

🎯 요약

명세를 참고해 타입을 생성하자

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


  • 타입의 이름을 짓는 것은 설계에서 중요하다.
    -> 엄선된 타입, 속성, 변수의 이름은 의도를 명확히 하고 코드와 타입의 추상화 수준을 높여준다.
// bad
interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}
const leopard: Animal = {
  name: "Snow Leopard",
  endangered: false,
  habitat: "tundra",
};

// 전문 용어들이 사용
// good
interface Animal {
  commonName: string;
  genus: string;
  species: string;
  status: ConservationStatus;
  climates: KoppenClimate[];
}
type ConservationStatus = "EX" | "EW" | "CR" | "EN" | "VU" | "NT" | "LC";
type KoppenClimate = "Af" | "Am" | "As";

const snowLeopard: Animal = {
  commonName: "Snow Leopard",
  genus: "Panthera",
  species: "Uncia",
  status: "VU",
  climates: ["Af", "Am"],
};
  • 코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 있다.
    -> 만들어 내지 말고, 이미 존재하는 용어를 사용하자

  • 타입, 속성, 변수에 이름을 붙일 때 3가지 규칙

  1. 동일한 의미를 나타낼 때는 같은 용어를 사용하자

  2. data, info, thing, item, object, entity 같은 모호하고 의미 없는 이름을 피하자

  3. 이름을 지을 때는 데이터 자체가 무엇인지 고려하자

    예시) INodeList 보다는 Directory가 더 의미 있는 이름

🎯 요약

가독성과 추상화 수준을 높이기 위해 전문 용어를 정확하게 사용하자

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


  • 타입스크립트는 구조적 타이핑을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있다.
    -> 값을 구분하기 위해 상표를 붙이는 것을 고려하자

  • 상표 기법

    • 타입 시스템에서 동작하지만, 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.
    • 타입 시스템이기 때문에, 런타임 오버헤드를 없앤다.
    • 추가 속성을 붙일 수 없는 string이나 number 같은 내장 타입도 상표화 가능

1. string 에서의 상표 기법

// 런타임에 절대 경로('/')로 시작하는지 체크하기 쉽지만,
// 타입 시스템에서는 절대 경로를 판단하기 어렵기 때문에 상표 기법 사용
type AbsolutePath = string & { _brand: 'abs' }
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/')
}
function f(path: string) {
  if (isAbsolutePath(path)) {
    listAbsolutePath(path)
  }
  listAbsolutePath(path)
  // ~~~~ 'string' 형식의 인수
  //      AbsolutePath' 형식의 매개변수에 할당될 수 없습니다.
}

2. 타입 시스템 내에서 표현할 수 없는 수많은 속성들을 모델링할 수 있다.

// 예시: 목록에서 한 요소를 찾기 위해 이진 검색을 하는 경우
// 이진 검색은 이미 정렬된 상태를 가정하기 때문에, 목록이 정렬되어 있지 않다면 잘못된 결과가 나온다.
function binarySearch<T>(xs: T[], x: T): boolean {
  let low = 0,
    high = xs.length - 1;
  while (high >= low) {
    const mid = low + Math.floor((high - low) / 2);
    const v = xs[mid];
    if (v === x) return true;
    [low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
  }
  return false;
}

// 타입스크립트 타입 시스템에서는 목록이 정렬되어 있다는 의도를 표현하기 어렵다. 
// 상표 기법을 사용해 보자.
type SortedList<T> = T[] & { _brand: "sorted" };

function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] < xs[i - 1]) return false;
  }
  return true;
}
function binarySearch<T>(xs: SortedList[], x: T): boolean {}

3. number 타입에도 가능. 숫자의 단위를 문서화할 수 있다.

/* 산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있다.
 그러나 코드에 여러 단위가 혼합된 많은 수의 숫자가 들어 있는 경우, 
 숫자의 단위를 문서화하는 괜찮은 방법일 수 있다.*/

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

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000); // Meters
const oneMin = seconds(50); // Seconds

const tenKim = oneKm * 10; // number
const v = oneKm / oneMin; // number

🎯 요약

값을 구분하기 위해서 상표 기법을 고려해보자

✅ 참고자료
아이템35
아이템37

profile
블로그 이전 했습니다. https://yoon-log.vercel.app/
post-custom-banner

0개의 댓글