Effective Typescript (Day2)

d_fe·2022년 11월 28일
post-thumbnail

2. 타입스크립트의 타입 시스템

item6. 편집기를 사용하여 타입 시스템 탐색하기

  • 타입스크립트 컴파일러 (tsc)
  • 단독으로 실해라 수 있는 타입스크립트 서버 (tsserver)

타입스크립트 설치 시 위의 두 가지를 실행할 수 있는데, 보통은 편집기를 통해서 언어 서비스를 사용하니 타입스크립트 서버에서 언어 서비스를 제공하도록 설정하는 게 좋다.
편집기는 TS가 언제 타입 추론을 수행할 수 있는지에 대한 개념을 잡게 해준다.

보통 심벌 위에 마우스 커서를 대면 TS가 그 타입을 어떻게 판단하고 있는지 확인할 수 있으며, 함수의 경우 반환타입을 지정해주지 않았어도 매개변수의 타입을 통해 반환값의 타입을 추론할 수 있다.

(but, 그 반환값이 기대한 것과 다르면 선언을 직접 명시하고, 문제 발생 부분을 찾아 볼 것)


조건문의 분기에서 값의 타입이 어떻게 변하는지 살펴보는 것은 타입 시스템을 연마하는 매우 좋은 방법이다. 위 코드에서 조건문 외부에서의 타입은 string | null 이지만 내부는 string이다.


객체에서는 개별 속성을 살펴봄으로써 타입스크립트가 어떻게 각각의 속성을 추론하는지 살펴볼 수 있다. 위 코드에서 만약 x가 튜플 타입([number, number, number]) 이어야 한다면, 타입 구문을 직접 명시해줘야 한다.


연산자 체인 중간의 추론된 제너릭 타입을 알고 싶다면, 메서드 이름을 조사하면 된다.
위에서 Array<string> 은 split 결과의 타입이 string이라고 추론되었음을 의미한다.


편집기상의 타입 오류를 살펴보는 것도 타입 시스템의 성향을 파악하는 데 좋은 방법이다.
첫 번째 오류는 JS에서 typeof null 은 'object' 이므로 elOrId가 여전히 분기문 내에서 null일 가능성이 있어 발생했고, 두 번째 오류 또한 document.getElementById(elOrId) 가 null을 반환할 가능성이 있어 발생했다.
각각 처음에 null 체크를 추가해 위 오류들을 바로잡을 수 있다.

// 필자의 해결방법
function getElement(elOrId: string|HTMLElement|null): HTMLElement {
    if(typeof elOrId === 'object' && elOrId !== null) {
        return elOrId
    } else if (elOrId === null) {
        return document.body;
    } else {
        const el = document.getElementById(elOrId);
        if(el === null) throw Error('error')
        return el;
    }
}


언어 서비스는 라이브러리와 라이브러리의 타입 선어르 탐색할 때 도움이 된다.
편집기는 '정의로 이동' 옵션을 제공하여 TS의 DOM 타입 선언인 lib.dom.d.ts 로 이동하고 어떻게 모델링 되었는지 살펴볼 수 있다. TS가 동작을 어떻게 모델링하는지 알기 위해 타입 선언 파일을 찾아보는 방법을 터득해야 한다.


item 7. 타입이 값들의 집합이라고 생각하기

런타임에 모든 변수는 JS 세상의 값으로부터 정해지는 각자의 고유한 값을 가진다.
하지만 코드가 실행되기 전, TS가 오류를 체크하는 순간에는 '타입'을 가지고 있다.
'할당 가능한 값들의 집합'이 타입이라 생각하면 되고, 이 집합은 타입의 '범위'라고도 한다.

  • 가장 작은 집합의 범위는 공집합이며, never 타입이다.
const x:never = 12;
// '12' 형식은 'never' 형식에 할당할 수 없습니다.
  • 다음으로 작은 집합은 한 가지 값만 포함하는 타입, 유닛 타입이라고도 불리는 리터럴 타입이다.
  • 이를 두 개 혹은 세 개로 묶으려면 유니온 타입을 사용한다. (값 집합들의 합집합)
// 리터럴 타입
type A = 'A';
type B = 'B';
type Twelve = 12;

//유니온 타입
type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;

// '할당 가능한' 의 의미
const a: AB = 'A'; //정상, 'A' 는 집합 {'A','B'} 의 원소이므로
const c: AB = 'C' ; // 'C' 형식은 'AB' 형식에 할당할 수 없습니다. 
집합의 관점에서 타입 체커의 역할은 하나의 집합이 다른 집합의 부분 집합인지 검사하는 것

++ declare 을 이용해 타입 지정하는 것이 무엇을 의미하는 것인지 찾아보았다.

  • 외부 JS 파일을 이용할 경우 import를 사용하는데 그 파일이 TS가 아니라 JS파일이라면 무수한 에러가 발생한다. (타입 지정이 안되었기 때문에) 따라서 declare을 통해 이미 정의된 변수나 함수를 재정의 하여 컴파일러에게 힌트를 준다.
  • TIP : tsconfig.json 안에 allowJS 옵션을 true로 켜두면 JS파일도 타입지정이 알아서 된다고 한다.
  • 자세한 내용 출처 : https://velog.io/@hosickk/Typescript-declare
  • 타입 연산자
interface Person {
	name: string;
}

interface Lifespan {
    birth: Date;
    death?: Date;
}

type PersonSpan = Person & Lifespan

const ps : PersonSpan = {
    name: 'Lana',
    birth: new Date('1900/01/01'),
    death: new Date('2000/01/01')
}

type K = keyof( Person | Lifespan); // type은 never

& 연산자는 두 타입의 인터섹션(교집합)을 계산한다.
위 코드에서 언뜻 두 타입이 공통으로 가지는 속성이 없어 never 타입으로 예상할 수 있다.
하지만 타입 연산자는 인터페이스의 속성이 아닌, 값의 집합(타입의 범위)에 적용되며 추가적인 속성을 가지는 값도 여전히 그 타입에 속한다.
인터섹션 타입의 값은 각 타입 내의 속성을 모두 포함하는 것이 일반적이다.
(따라서, Person 과 Lifespan을 둘 다 가지는 값은 인터섹션 타입에 속한다)

type (A & B) = (keyof A) | (keyof B)
type (A | B) = (keyof A) & (keyof B)

위 등식이 타입 시스템을 이해하는 데 큰 도움이 될 것이다.

++ typeof 과 keyof에 대해

  • typeof : 객체 데이터를 객체 타입으로 변환해주는 연산자
const obj = {
	red: 'apple',
  	yellow: 'banana',
  	green: 'cucumber',
};
type Fruit = typeof obj;
// Fruit 의 타입은 {red: string; yellow:string; green: string;}
  • keyof : 객체 형태의 타입을, 따로 속성들만 뽑아 유니온 타입으로 만들어주는 연산자
type Type = {
	name: string;
  	age: number;
  	married: boolean;
}
type Union = keyof Type;
// Union의 타입은 name|age|married

출처 : https://inpa.tistory.com/entry/TS-%F0%9F%93%98-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-keyof-typeof-%EC%82%AC%EC%9A%A9%EB%B2%95#typeof_%EC%97%B0%EC%82%B0%EC%9E%90

but, 일반적으로는 extends 문법을 사용한다.

interface Person {
	name: string;
}

interface PersonSpan extends Person {
    birth: Date;
    death?: Date;
}

위와 같이 extends를 '~의 부분 집합' 이라는 의미로 해석하면 이해가 편하다.
타입들이 엄격한 상속 관계가 아닐 때는 집합 스타일이 더욱 바람직하다.

// 배열과 튜플의 관계 파악이 용이해진다.
const list = [1,2] // type => number[]
const tuple: [number,number] = list; // error
// 반대로 하면 오류가 발생하지 않는다.


const triple : [number,number,number] = [1,2,3];
const double: [number,number] = triple;
// error

위 코드에서 TS는 숫자의 쌍을 {0: number, 1: number} 로 모델링하지 않고, {0:number, 1: number, length: 2} 로 모델링한다. 따라서 length의 값이 맞지 않기 때문에 오류가 발생한다.


item.8 타입 공간과 값 공간의 심벌 구분하기

심벌은 이름이 같더라도 속하는 공간에 따라 다른 것을 나타낼 수 있다.

interface Cylinder {
	radius: number;
  	height: number;
}

const Cylinder = (radius: number, height: number) => ({radius, height})
// ! 이름이 같으나 위는 타입으로 쓰이고, 아래는 값으로 쓰이며 서로 아무런 관련이 없다.

function calculateVolume (shape: unknown) {
    if(shape instanceof Cylinder) {
        shape.radius
        // Error : '{}' 형식에 'radius' 속성이 없습니다.
    }
}

위 함수는 instanceof을 이용해 shape 가 Cylinder 타입인지 체크하려 한 것으로 보인다. 하지만 instanceof은 JS 런타임 연산자이고, 값에 대해서 연산하기 때문에 interface로 지정한 Cylinder 타입이 아니라 const로 지정한 Cylinder 값을 참조하여 오류가 발생한다.

이와 같이 타입 공간과 값 공간을 구분하는 맥락을 파악해야 한다.
https://www.typescriptlang.org/play/ 에서 TS 소스로부터 변환된 JS 결과물을 확인할 수 있는데, 컴파일 과정에서 타입 정보는 제거되기 때문에 심벌이 사라진다면 타입에 해당될 것이다.

class Cylinder {
    radius = 1;
    height = 1;
}

function calculateVolume(shape: unknown) {
    if (shape instanceof Cylinder) {
        shape; // type > Cylinder
        shape.radius; // type > number
    }
}

const v = typeof Cylinder; // 값이 function
//JS에서 class는 실제 함수로 구현됨.
type T = typeof Cylinder; // 타입이 typeof Cylinder

❗한편, class와 enum 은 상황에 따라 타입과 값 두 가지 모두 가능한 예약어이다.
따라서 위 코드의 클래스에 대한 typeof은 상황에 따라 다르게 동작한다.

❗속성 접근자인 []는 타입으로 쓰일 때에도 동일하게 동작하나, obj['field'] 와 obj.field는 값이 동일하더라도 다른 타입일 수 있다. 따라서 타입의 속성을 얻을 때에는 반드시 obj['field'] 로 하자.

! 값 공간과 타입 공간 사이에서 다른 의미를 가지는 코드 패턴들

  • 값으로 쓰이는 this는 JS의 this 키워드이며, 타입으로 쓰이는 this는 '다형성 this'라 불리는 this의 TS 타입이다.
  • 값에서 & 와 |는 AND와 OR 비트연산이며, 타입에서는 인터섹션과 유니온이다.
  • const는 새 변수를 선언하지만 as const는 리터럴 또는 리터럴 표현식의 추론된 타입을 바꾼다.
  • extends는 서브클래스 또는 서브타입 또는 제너릭 타입의 한정자를 정의할 수 있다.
  • in은 루프 또는 매핑된 타입에 등장한다.

❗타입 스크립트에서 destructing을 하면, 이상한 오류가 발생한다.

// Error
function email({person: Person, subject: string, body: string}) {/* ... */}

// correct
function email({person, subject, body} : {person: Person, subject: string, body: string})
{/* ... */}

위 Error 코드는 객체 자체가 넘어가 값의 관점에서 Person 과 string이 해석되었기 때문에 오류가 발생한다. 따라서 destructing을 아래 코드처럼 이용하려면 값과 타입을 구분해야 한다.


item 9. 타입 단언보다는 타입 선언을 사용하기

TS에서 변수에 값을 할당하고 타입을 부여하는 방법은 두 가지다.

interface Person {name: string};

const alice: Person = {name: 'Alice}; // 타입 선언
const bob = {name: 'Bob'} as Person; // 타입 단언

타입 단언의 경우 TS가 추론한 타입이 있더라도 해당 타입으로 간주한다.
타입 단언은 강제로 타입을 지정했으니 타입 체커에게 오류를 무시하라고 하는 것이므로, 확실하지 않다면 안정성 체크도 되는 타입 선언을 사용하도록 한다.

❗ 화살표 함수의 타입 선언은 추론된 타입이 모호할 때가 있다.

interface Person {
    name: string;
}

const people = ['a','b','c'].map(name => {name});
// return type 은 {name: string;}[];

const people = ['a','b','c'].map(name => {name} as Person);
// 타입 단언
// return type 은 Person[]

const people = ['a','b','c'].map(name => {
    const person: Person = {name};
    return person;
})
// 타입 단언보다 화살표 함수 안에서 타입과 함께 변수를 선언하는 것이 직관적이다.
// return type 은 Person[]

const people = ['a','b','c'].map((name):Person => ({name}))
// 간결하게 개선

const people : Person[]= ['a','b','c'].map((name):Person => ({name}))
// 최종으로 원하는 값까지 명시

but, 함수 호출 체이닝이 연속되는 곳에서는 체이닝 시작에서부터 명명된 타입을 가져야 정확한 곳에서 오류가 표시된다.

! 그래서 타입 단언은 어디에 쓰이는가?
타입 체커가 추론한 타입보다 본인이 판단하는 타입이 더 정확할 때 의미가 있다.
✔️예를 들어, DOM 엘리먼트에 대해서는 TS보다 우리가 더 정확하다.

document.querySelector('#myButton')?.addEventListener('click',e => {
    e.currentTarget;
    const button = e.currentTarget as HTMLButtonElement;
    button
})

TS 는 DOM에 접근할 수 없기 때문에 #myButton 이 버튼 엘리먼트인지 알지 못한다.
또한 이벤트의 currentTarget이 같은 버튼이어야 하는 것도 알지 못한다.
이런 경우 TS가 알지 못하는 정보를 우리가 갖고 있기 떄문에 타입 단언문을 사용하는 것이 타당하다.

✔️또한 자주 쓰이는 특별한 문법(!) 을 사용하여 null이 아님을 단언하는 경우가 있다.

const elNull = document.getElementById('foo'); // type 은 HTMLElement | null
const el = document.getElementById('foo')!; // type 은 HTMLElement

접미사로 쓰인 ! 는 그 값이 null이 아니라는 단언문으로 해석된다.
단언문은 컴파일 과정 중에 제거되므로, 타입 체커는 알지 못하지만 그 값이 null이 아니라고 확신할 수 있을 때 사용한다. 그렇지 않다면 null인 경우를 체크하는 조건문을 사용하자.


item 10. 객체 래퍼 타입 피하기

JS에는 객체 이외에도 기본형 값들에 대한 일곱 가지 타입이 있다.
(string, number, boolean, null, undefined, symbol, bigint)

기본형들은 불변(immutable)이며 메서드를 가지지 않는다는 점에서 객체와 구분된다.
하지만 'string'.charAt(3) 과 같이 메서드를 가지고 있는 것처럼 보인다.

JS는 기본형과 객체 타입을 서로 자유롭게 변환할 수 있기 때문에 기본형 string을 String 객체로 래핑(wrap)하고, 메서드를 호출, 마지막에 래핑한 객체를 버리는 식으로 작동한다.

❗TS는 기본형과 객체 래퍼 타입을 별도로 모델링한다.

function getStringLen(foo: String) {
	return foo.length;
}

getStringLen('hello');
getStringLen(new String('hello'));

String 타입의 매개변수에 string 타입을 넣으면 잘 동작하는 것처럼 보인다.

function isGreeting(phrase: String) {
	return [
    	'hello',
      	'good day',
    ].includes(phrase);
  // Error . 'String' 형식의 인수는 'string' 형식의 매개변수에 할당될 수 없습니다.
}

하지만, string 타입을 매개변수로 받는 메서드에 String 객체를 전달하는 순간 문제가 발생한다.
string은 String에 할당할 수 있지만, String은 string에 할당할 수 없다.

profile
오늘보다 내일 더 성장하는 프론트엔드 개발자가 되기 위해

0개의 댓글