ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [TS] 이펙티브 타입스크립트 : 2장 - 타입스크립트의 타입 시스템 (2)
    TypeScript 2022. 3. 17. 16:11

     

    아이템 11 : 잉여 속성 체크의 한계 인지하기

    객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 속성 체크가 수행된다.
    • 잉여 속성 체크는 해당 타입의 속성이 있는지, 그리고 '그 외의 속성은 없는지' 확인하는 과정을 뜻한다.
      이는 타입스크립트에서 일반적으로 수행되는 '구조적 할당 가능성 체크'와는 별도로 이루어지는 동작이다.
    • 잉여 속성 체크는 타입이 명시된 변수에 객체 리터럴을 할당할 때 실행된다.
      이를 이용하면 객체 리터럴에 알 수 없는 속성을 허용하지 않음으로써, 아래와 같은 문제점을 해결할 수 있다.
      interface Docs {
      	title: string;
      }
      
      
      /* 객체를 할당할 경우 나타나는 문제점 1 */
      const obj = {
      	title: 'Hello',
      	subTitle: 'Welcome!'
      };
      const r: Docs = obj; // 부분 집합을 기준으로 체크되기 때문에 정상
      
      /* 객체를 할당할 경우 나타나는 문제점 2 */
      const r: Docs = document; // document가 title을 포함하기 때문에 정상
      
      
      /* 객체 리터럴을 할당할 경우 */
      const r: Docs = {
      	title: 'Hello',
      	subTitle: 'Welcome!'
      }; // TypeError : 'Docs' 형식에 'subTitle'(이)가 없습니다.

    +) 일반적으로 객체 리터럴에서 발생하는 잉여 속성 체크를 "과잉 속성 검사"라고 부르며, 이 검사가 존재하는 이유는 프로그래머의 실수를 막기 위함이다. 어떤 타입의 값에 객체 리터럴을 직접 할당하는 경우, 해당 타입에 정의되지 않은 속성은 오타 등의 실수로 인해 존재할 확률이 높다고 가정하는 것이다.

    잉여 속성 체크에는 한계가 있다.
    • 임시 변수를 도입하면 잉여 속성 체크를 건너뛴다.
      const r = { title: 'Hello', subTitle: 'Welcome!' };
      const o: Docs = r; // 정상
       
    • 타입 단언문을 사용할 때에도 적용되지 않는다.
      const r = { title: 'Hello', subTitle: 'Welcome!' } as Docs; // 정상
    선택적 속성만 가지는 '약한(weak)' 타입에도 비슷한 체크가 동작한다.
    • 특정 타입의 모든 속성이 선택적인 경우, 공통된 속성이 있는지 확인하는 별도의 체크를 수행한다.
      interface Docs {
      	title?: string;
      }
      
      const o = { Title: 'Hello' };
      const r: Docs = o; // TypeError : ... '{ Title: string; }' 유형에 'Docs' 유형과 공통적인 속성이 없습니다.
    • 잉여 속성 체크와 다르게, 약한 타입과 관련된 할당문마다 모두 수행된다. (객체 리터럴인지 상관없음)

    아이템 12 : 함수 표현식에 타입 적용하기

    매개변수나 반환값에 타입을 명시하기보다는 함수 표현식 전체에 타입 구문을 적용하는 것이 좋다.
    • 타입스크립트에서는 (함수 문장보다) 함수 표현식을 사용하는 것이 좋다. 함수의 매개변수부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다는 장점이 있기 때문이다.
      type FuncType = (num: number) => number;
      const Func: FuncType = num => { /* ... */ };
    • 함수 타입의 선언은 함수 시그니처를 하나의 함수 타입으로 통합할 수 있게 해준다. (불필요한 코드 반복 감소)
      type FuncType = (a: number, b: number) => number;
      const add: FuncType = (a, b) => a + b;
      const sub: FuncType = (a, b) => a - b;
    라이브러리는 공통 함수 시그니처를 타입으로 제공하기도 한다.
    • 만약 같은 타입 시그니처를 반복적으로 작성한 코드가 있다면 함수 타입을 분리해 내거나 이미 존재하는 타입을 찾아보는 것을 권장한다.
      • ex) 리액트의 경우, 함수의 매개변수에 명시하는 MouseEvent 타입 대신에 함수 전체에 적용할 수 있는 MouseEventHandler 타입을 제공함.
    • 라이브러리를 직접 만들고 있다면, 공통 콜백 함수를 위한 타입 선언을 제공하는 것이 좋다.
    typeof fn 을 사용하면 다른 함수의 시그니처를 참조할 수 있다.
    • fetch 함수 예시를 살펴보자!
      1. 웹 브라우저에서 fetch 함수는 특정 리소스에 HTTP 요청을 보내고 response를 받는다. 그리고 response에서 json() 또는 text() 메서드를 사용해 데이터를 추출할 수 있다.
        async function getUser() {
        	const response = await fetch('/user?name=jiwon');
        	const user = await response.json();
        	return user;
        }
      2. 만약 /user가 존재하지 않는 API일 경우, 응답은 '404 Not Found'가 날라온다. 이런 경우 응답은 JSON 형식이 아닐 수 있다.
        이 때, response.json()은 JSON 형식이 아니라는 새로운 오류 메시지를 담아 거절된 프로미스를 반환한다. 결과적으로 실제 오류인 404가 감춰지게 되는 것이다.
      3. 위와 같은 일이 벌어지지 않도록 상태 체크를 수행해 줄 checkedFetch 함수를 작성해보자.
        /* fetch 타입 선언 */
        declare function fetch(
        	input: RequestInfo, init?: RequestInit
        ): Promise<Response>{ ... };
        
        
        /* checkedFetch 함수 */
        async function checkedFetch(input: RequestInfo, init?: RequestInit) {
        	const response = await fetch(input, init);
        	if (!response.ok) {
        		throw new Error('Request failed: ' + response.status);
        	}
        	return response;
        }
      4. 다음으로 위 코드를 함수 표현식으로 바꾸고, 함수 전체에 타입(typeof fetch)을 적용해보자.
        이는 타입스크립트가 input, init 타입을 추론할 수 있게 해주며, checkedFetch 함수의 반환 타입을 보장한다. (fetch와 동일)
        const checkedFetch: typeof fetch = async (input, init) => { ... }
    • 이처럼 함수의 매개변수에 타입 선언을 하는 것보다 함수 표현식 전체 타입을 정의하는 것이 코드도 간결하고 안전하다.
    • 다른 함수의 시그니처와 동일한 타입을 가지는 새 함수를 작성하거나, 동일한 타입 시그니처를 가지는 여러 개의 함수를 작성할 때는 매개변수의 타입과 반환 타입을 반복해서 작성하지 말고 함수 전체의 타입 선언을 적용해야 한다.

    아이템 13 : 타입과 인터페이스의 차이점 알기

    타입과 인터페이스의 공통점과 차이점을 이해해야 하며, 두 가지 문법을 사용해서 작성하는 방법을 터득해야 한다.
    • 타입스크립트에서 명명된 타입을 정의하는 방법은 타입 별칭(Type Aliases)인터페이스(Interface) 두 가지가 있다.
      /* 타입 별칭 */
      type User = { name: string; }
      
      /* 인터페이스 */
      interface User { name: string; }
    • 타입과 인터페이스의 공통점 :
      • 인덱스 시그니처는 인터페이스와 타입에서 모두 사용할 수 있다. 
        type FnType = { [key: string]: string };
        interface FnType {
        	[key: string]: string;
        }
      • 함수 타입도 인터페이스나 타입으로 정의할 수 있다.
        type FnType = (x: number) => string;
        interface FnType {
        	(x: number): string;
        }
      • 타입과 인터페이스는 모두 제너릭이 가능하다.
        type FnType<T> = {
        	first: T;
        	second: T;
        }
        interface FnType<T> {
        	first: T;
        	second: T;
        }
    • 타입과 인터페이스의 차이점
      • 인터페이스는 extends 키워드로 타입을 확장하고, 타입은 & 연산자로 인터페이스를 확장할 수 있다.
        (클래스 구현 시, 둘 다 사용 가능)
        interface InterfaceStateWith extends TypeState {
        	name: string;
        }
        type TypeStateWith = InterfaceState & { name: string; };
      • 타입은 유니온 타입, 튜플과 배열 타입, 매핑된 타입, 조건부 타입 같은 복잡한 타입 사용이 가능하다.
        // 유니온 타입
        type Set = 'a' | 'b';
        
        // 매핑된 타입
        type CheckType = { [K in Set]: boolean };
        
        // 튜플/배열 타입
        type Tuple = [number, number];
        type SetNums = [string, ...number[]];
        
        // 조건부 타입
        type TypeName<T extends boolean> = T extends true ? 'O' : 'X';
      • 인터페이스는 '선언 병합(declaration merging)'이 가능하다.
        /* 인터페이스는 속성 확장이 가능 */
        interface User {
        	name: string;
        }
        interface User {
        	age: number;
        }
        const jiwon: User = {
        	name: 'jiwon',
            age: 123
        }; // 정상
        
        /* 타입은 불가능 (한 번 생성하면 끝) */
        type User = { name: string }
        type User = { age: number } // Error: Duplicate identifier 'User'.
    대부분의 경우 타입과 인터페이스 중 무엇을 사용하든 상관없지만, 같은 상황에서는 동일한 방법으로 명명된 타입을 정의하여 일관성을 유지해야 하기 때문에 둘의 차이를 분명히 알고 사용해야 한다.
    • 복잡한 타입이라면 타입 별칭 사용
    • 선언 병합의 가능성을 고려한다면 인터페이스 사용
    • 두 가지 방법으로 모두 표현할 수 있는 간단한 객체타입이라면 한 가지 일관된 스타일로 확립!

    아이템 14 : 타입 연산과 제너릭 사용으로 반복 줄이기

    DRY(don't repeat yourself) 원칙을 타입에도 최대한 적용해야 한다.
    • 같은 코드를 반복하지 말라! 타입 중복은 코드 중복만큼 많은 문제를 발생시킨다.
    • 반복을 피하는 간단한 방법은 아래와 같다.
      • 타입에 이름 붙이기
        // 수정 전
        function distance(a: { x: number, y: number }, b: { x: number, y: number }) {
        	/* ... */
        }
        
        // 수정 후
        interface Vector2D {
        	x: number;
        	y: number;
        }
        function distance(a: Vector2D, b: Vector2D) { /* ... */ }
      • 같은 시그니처를 공유할 경우, 명명된 타입으로 분리하기
        // 수정 전
        function get(url: string, opts: Options) : Promise<Response> { /* ... */ }
        function post(url: string, opts: Options) : Promise<Response> { /* ... */ }
        
        // 수정 후
        type HTTPFn = (url: string, opts: Options) => Promise<Response>;
        const get: HTTPFn = (url, opts) => { /* ... */ }
        const post: HTTPFn = (url, opts) => { /* ... */ }
         
      • 인터페이스 필드에 extends 사용하기
        interface Person {
        	name: string;
        	age: number;
        }
        
        interface PersonContact extends Person {
        	email: string;
        }
      • 이미 존재하는 타입을 확장하는 경우, 인터섹션 연산자(&) 응용하기
        type PersonContact = Person & { email: string };
      • 단순히 타입을 정의하는 경우, 유니온 인덱싱하기
        interface SuccessMessage {
        	type: 'success';
        	// ...
        }
        
        interface ErrorMessage {
        	type: 'error';
        	// ...
        }
        
        type Message = SuccessMessage | ErrorMessage;
        
        // 수정 전 : 타입의 반복
        type MessageType = 'success' | 'error';
        
        // 수정 후 : 반복 없이 정의
        type MessageType = Message['type'];
    keyof, typeof, 인덱싱, 매핑된 타입 등 타입스크립트가 제공하는 도구들을 활용하면 좋다.
    • 아래와 같이 특정 부분만 표현해야 하는 경우, 타입을 확장하기보다는 부분 집합으로 정의하는 것이 바람직하다.
      interface Person {
      	name: string;
      	age: number;
      	email: string;
      	phone: string;
      }
      
      interface Contact {
      	email: string;
      	phone: string;
      }
    • 타입스크립트에서 제공하는 여러 도구를 활용해 부분 집합으로 정의할 수 있다.
      • 인덱싱 (기본)
        type Contact = {
        	email: Person['email'];
        	phone: Person['phone'];
        }
      • 인덱싱 (매핑된 타입) : 반복되는 코드를 줄일 수 있음.
        type Contact = {
        	[k in 'email' | 'phone']: Person[k];
        }
      • 표준 라이브러리의 Pick : T와 K 두 가지 타입을 받아서 결과 타입을 반환함. (제너릭 타입)
        // type Pick<T, K extends keyof T> = { [k in K]: T[k] };
        
        type Contact = Pick<Person, 'email' | 'phone'>;
    표준 라이브러리에 정의된 제너릭 타입을 활용해야 한다.
    • 제너릭 타입은 타입을 위한 함수와 같다. 타입을 반복하기보다는 제너릭 타입을 사용하여 타입들 간에 매핑을 하는 것이 좋다.
      • Partial : 매핑된 타입과 keyof 사용 방식과 동일 (ex. 생성 후 업데이트 되는 클래스 정의할 경우)
        interface Properties {
        	size: number;
        	weight: number;
        	color: string;
        }
        
        class Typo {
        	constructor(init: Properties) { /* ... */ }
        	update(options: PropertiesUpdate) { /* ... */ } // 업데이트 속성이 선택적일 때
        }
        
        // 수정 전
        interface PropertiesUpdate {
        	size?: number;
        	weight?: number;
        	color?: string;
        }
        
        // 수정 후
        type PropertiesUpdate = { [k in keyof Properties]?: Properties[k] };
        
        // Partial 사용 시 따로 정의하지 않고 바로 사용 가능
        class Typo {
        	...
        	update(options: Partial<Properties>) { /* ... */ }
        }
      • ReturnType : 적용 대상이 값인지 타입인지 정확히 알고 사용해야 함. (ex. 함수나 메서드의 반환 값에 명명된 타입을 만들 경우)
        function getUser(userId: string) {
        	...
        	return { name, age, email, phone };
        }
        
        // 추론된 타입 바탕으로 추출
        type User = ReturnType<typeof getUser>;
    • 제너릭 타입을 제한하려면 extends 를 사용하면 된다. 이를 이용하면 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있다.
      interface PersonInfo {
      	name: string;
      	age: number;
      }
      type Person<T extends PersonInfo> = [T, T];
      
      // 정상 작동
      const person: Person<PersonInfo> = [
      	{ name: 'jiwon', age: 123 },
      	{ name: 'soju', age: 456 }
      ];
      
      // 'PersonInfo'를 확장하지 않았기 때문에 에러 발생
      const person: Person<{ name: string }> = [
      	{ name: 'jiwon' },
      	{ name: 'soju' }
      ]; // 'PersonInfo' 타입에 필요한 'age' 속성이 '{ name: string }' 타입에 없습니다.

    댓글

leejiwonn