아이템 10 객체 래퍼 타입 피하기
자바스크립트의 타입
기본형 값들에 대한 일곱가지 타입 (string, number, boolean, null, undefined, symbol(ES2015에서 추가), bigint(최종 확정 단계))
객체
기본형은 불변이며, 메서드를 가지지 않는다. 그런데 기본형인 string이 메서드를 가진 것처럼 보이는 이유는 자바스크립트에 메서드를 가지는 String 객체 타입
이 정의되어 있기 때문이다. 만약 charAt과 같은 메서드를 기본형에 사용한다면 기본형을 String 객체로 래핑하여 메서드를 호출한 후 마지막에 래핑한 객체를 버리는 방식으로 작동된다. null과 undefined를 제외한 모든 기본형에 이와 같은 객체 래퍼 타입이 존재한다. 이러한 래퍼 객체는 직접 생성할 필요가 없고, 기본형을 사용해야하는데 래퍼 객체를 사용하지 않도록 주의하여야 한다. 타입스크립트가 제공하는 타입 선언은 전부 기본형 타입이다. 다만 기본형 타입은 객체 래퍼에 할당할 수 있기 때문에 타입스크립트는 기본형 타입을 객체 래퍼에 할당하는 것을 허용한다. 그러나 이런 방법은 오해하기 쉽고 이렇게 쓰지 않는 것이 좋다.
아이템 11 잉여 속성 체크의 한계 인지하기
타입이 명시된 변수에 객체 리터럴을 할당할 때 타입스크립트는 해당 타입의 속성이 있는지, 그 외의 속성은 없는지 확인한다.
1 | //1️⃣ 잉여 속성 체크 |
1️⃣ 의 샘플코드는 잉여 속성 체크
가 수행되었다. 구조적 타입 시스템에서 발생할 수 있는 중요한 오류를 잡을 수 있도록 한다. 1️⃣ 의 샘플코드는 구조적 타입의 관점에서 생각해보면 elephant 속성이 있어도 오류가 발생하지 않아야 하지만, 오류가 발생했다. 이처럼 잉여 속성 체크를 사용하면 타입 시스템의 구조적 본질을 해치치 않으면서도 객체 리터럴에 알 수 없는 속성을 허용하지 않음으로써 문제의 발생을 방지 할 수 있다. 단, 조건에 따라 동작하지 않을 수 있고, 통상적인 할당 가능 검사와 함께 쓰이면 구조적 타이핑이 무엇인지 혼란스러워 진다.
1 | interface Room { |
위 예시에서 intermediate 변수의 오른쪽은 객체 리터럴이지만 o변수의 intermediate는 객체 리터럴이 아니다. 타입 구문이 없는 임시 변수이다. 이 경우 잉여 속성 체크가 적용되지 않아서 오류가 사라진다. 위의 예시에서 o2와 같이 타입 단언을 사용해도 잉여 속성 체크는 적용되지 않는다. 아래 예시처럼 선택적 속성만 가지는 약한 타입에도 비슷한 체크가 동작한다.
1 | interface LineChartOptions { |
위 예시에서 LineChartOptions 타입은 모든 속성이 선택적이므로 모든 객체를 포함할 수 있는 약한 타입이다. 이 경우 타입스크립트가 값 타입과 선언 타입에 공통된 속성이 있는지 확인하는 별도의 체크를 수행한다. 오타를 잡는데 효과적이며 구조적으로 엄격하지 않지만 잉여 속성 체크와 다른 점은 약한 타입과 관련된 할당문마다 수행된다는 점이다. 따라서 임시 변수를 제거하더라도 공통 속성 체크는 여전히 동작한다.
TL; DR
- 객체 리터럴을 변수에 할당하거나 함수에 매개변수로 전달할 때 잉여 체크 속성이 수행된다.
- 잉여 속성 체크는 오류를 찾는 효과적인 방법이지만, 타입스크립트 타입 체커가 수행하는 일반적인 구조적 할당 가능성 체크와 역할이 다르다. 할당의 개념을 정확히 알아야 잉여 속성 체크와 일반적인 구조적 할당 가능성 체크를 구분할 수 있다.
- 잉여 속성 체크에는 한계가 있다. 임시 변수를 도입하면 잉여 속성 체크를 건너뛸 수 있다.
아이템 12 함수 표현식에 타입 적용하기
자바스크립트에서는 함수 문장(statement)와 함수 표현식(expression)을 다르게 인식한다.
1 | function rollDice1(sides: number): number { |
타입스크립트에서는 함수 표현식
을 사용하는 것이 좋다. 그 이유는 함수의 매개변수부터 반환값까지 전체를 함수 타입으로 선언하여 함수 표현식에 재사용할 수 있다는 장점이 있기 때문이다.
1 | //1️⃣ 사칙연산을 하는 함수 - 함수의 매개변수에 타입 선언 |
1️⃣ 에 비해 2️⃣ 가 코드가 간결하고 안전하다.
아이템 13 타입과 인터페이스 차이점 알기
타입스크립트에서 명명된 타입을 정의하는 방법은 아래와 같이 type
, interface
의 두 가지 방법이 있다. 대부분의 경우 둘 중 어떤 것을 사용해도 상관없지만 두 가지 타입이 가지는 차이점을 명확히 알고 사용해야 한다.
1 | type TState = { |
type
, interface
의 비슷한 점
- 명명된 타입은
type
,interface
둘 중 어떤 것으로 정의하든 상태에 차이가 없지만, 추가 속성과 함께 할당하려고 하면 오류가 발생한다.
1 | //예제코드에서 type은 T, interface는 I를 접두사로 사용했는데, |
- 인덱스 시그니처의 사용이 가능하다.
1 | type TDict = { [key: string]: string }; |
- 함수 타입도 정의할 수 있고, 제너릭이 가능하다.
1 | type TFn = (x: number) => string; |
- 타입을 확장할 수 있다. 단 interface는 주의사항이 몇 가지 있다.
1 | type TState = { |
- class를 사용하면 type, interface 모두 사용 가능.
type
, interface
의 다른 점
- interface는 타입을 확장할 수 있고, 유니온은 할 수 없다. 유니온 타입은 있지만 유니온 인터페이스라는 개념은 없다.
1 | type Input = { |
type
키워드는 interface보다 쓰임새가 많은데, 유니온이 될 수도 있고, 매핑된 타입 또는 조건부 타입 같은 고급 기능에도 활용된다. 튜플과 배열 타입을 표현하는 것도 용이하다. interface로 튜플과 비슷하게 구현할 수 있지만 concat과 같은 메서드를 사용할 수 없다.
- interface는 보강이 가능하고, type은 그렇지 않다.
1 | interface IState { |
위 예제에서 속싱이 확장되었는데 이를 선언 병합(declaration merging)
라고 한다. 선언 병합은 주로 타입 선언 파일에 사용횐다. 즉 타입 선언 파일을 작성할 때는 선언 병합을 지원하기 위해 반드시 interface를 사용해야 한다.
type
, interface
를 언제 써야하는가?
- 타입 선언 파일 뿐 아니라 일반적인 코드에서도 병합이 지원된다. 따라서 보강이 있는 경우는 interface, 기존 타입에 추가 보강이 없는 경우는 type을 쓴다.
- 복잡한 타입은 타입 별칭을 사용한다.
- type, interface 두 가지 모두로 표현할 수 있는 간단한 객체 타입이라면?
- 일관성과 보강의 관점을 고려해 본다.
- 코드베이스에서 일관되게 type을 쓰고 있다면 type을, interface를 쓰고 있다면 interface를 쓴다.
- API에 대한 타입 선언은 API가 변경될 때 사용자가 interface를 통해 새로운 필드를 병합할 수 있으니 interface를 쓴다. 단, 프로젝트 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계이며, 이럴 때는 type을 쓴다.
아이템 14 타입 연산과 제너릭 사용으로 반복 줄이기
코드를 작성할 때 코드를 반복하지 말라는 DRY(don’t repeat yourself)원칙에 따라 코드 중복을 제거하려고 노력하는 사람도 타입에 대해 간과하기 쉽다. 그 이유는 중복을 제거하는 매커니즘이 기존 코드에 대해 행하던 것에 비해 익숙치 않기 때문이다. 그러나 타입의 중복도 많은 문제를 일으키기 때문에 중복을 최소화해야 한다.
반복을 줄이는 방법
타입에 이름을 붙인다.
아래 예시에서 파라미터 a, b에 반복되는 타입인{ x: number; y: number }
은 Point2D interface로 이름을 붙여서 중복을 제거하였다. 몇몇 함수가 같은 타입 시그니처를 공유한다고 할 때도 해당 시그니처를 명명된 타입으로 본리할 수 있다.
1 | function distance(a: { x: number; y: number }, b: { x: number; y: number }) { |
interface를 사용할 경우, 한 interface가 다른 interface를 확장하게 해서 반복을 제거한다.
1 | interface Person { |
이미 존재하는 타입을 확장한다면 intersection 연산자(&)을 쓴다.
단, 일반적이지는 않다. 주로 확장할 수 없는 유니온 타입에 속성을 추가하려고 할 때 유용한 방법이다.
1 | interface Person { |
매핑된 타입
을 사용한다.
아래 예시에서 State는 전체 어플리케이션의 상태, TopNavState는 부분만 표현하는 상태라고 하고, 어떻게 매핑된 타입을 사용하는지 살펴보자. TopNavState를 확장해서 State를 구성할 수도 있지만, 의미상 State의 TopNavState를 정의하는 것이 바람직 할 것이다.
1 | interface State { |
아래 예시에서 1️⃣은 의미상 TopNavState를 State의 부분 집합이 되도록 작성하기 위해 State를 인덱싱해서 속성의 타입에 중복을 제거하였다. 이렇게 하면 State에 있는 속성의 타입이 바뀌더라도 잘 반영된다. 하지만 여전히 반복되는 코드가 있기 때문에 2️⃣와 같이 매핑된 타입을 사용한다. 매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식이다. 표준 라이브러리에서는 Pick
이라 한다. Pick은 제너릭 타입이며, 3️⃣과 같이 사용한다.
1 | //1️⃣ - 인덱싱하여 중복제거 |
태그된 유니온에서 중복이 발생하면 어떻게 할 수 있을까? 아래 예시에서 ‘save’,’load’가 중복된다.
1 | interface SaveAction { |
위와 같이 중복이 발생할 때는 Action 유니온을 인덱싱하여 ActionType을 정의한다. 이제 Action에 타입이 더 추가되더라도 ActionType은 자동으로 그 타입을 포함하게 된다.
1 | type ActionType = Action['type']; // Type is "save" | "load" |
매핑된 타입과 keyof를 사용한다.
아래와 같이 생성한 후 업데이트가 되는 클래스를 정의할 때를 가정해본다.
1 | interface Options { |
이때 매핑된 타입과 keyof를 사용하여 OptionsUpdate를 만든다.
- keyof는 타입을 받아서 속성 타입의 유니온을 반환한다.
- 매핑된 타입[k in keyof Options]은 순회하며 Options 내 k 값에 해당하는 속성이 있는지 찾는다.
?
는 속성을 선택적으로 만드는데 표준 라이브러리에는Partial
이라는 이름으로 포함되어 있다.
1 | type OptionsUpdate = { [k in keyof Options]?: Options[k] }; |
typeof
를 사용한다. 값의 형태에 해당하는 타입을 정의하고 싶을 때 사용하는 방법이다. 자바스트립트의 typeof처럼 보이지만 실제로는 타입스크립트 단계에서 연산된다. 단, 값으로부터 타입을 만들어 낼 때 선언의 순서에 주의한다. 타입 정의 후 값이 그 타입에 할당 가능하다고 선언하는 것이 명확하고, 예상하기 어려운 타입 변동을 방지할 수 있다.
1 | const INIT_OPTIONS = { |
- 함수나 메서드의 반환 값에 명명된 타입을 만들고 싶다면
ReturnType
을 사용한다. 아래 예시에서ReturnType
은 함수의값
인 getUserInfo가 아닌 typeof getUserInfo에 적용되었다.
1 | const INIT_OPTIONS = { |
제너릭 타입을 사용한다.
제너릭 타입은 타입을 위한 함수와 같다. 다만 함수에서 매개변수로 매핑할 수 있는 값을 제한하기 위해 타입 시스템을 사용하는 것처럼 매개변수를 제한할 수 있는 방법이 필요하다. 제너릭 타입에서 그 방법은extends
를 사용하는 것이다. 이는 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있게 한다.
1 | interface Name { |
아이템 15 동적 데이터에 인덱스 시그니처 사용하기
자바스크립트는 객체를 생성하는 문법이 간단하고, 문자열 키를 타입의 값에 관계없이 매핑할 수 있다. 타입스크립트에서는 타입에 인덱스 시그니처
를 명시하여 유연하게 매핑을 표현할 수 있다.
1 | //1️⃣ 인덱스 시그니처 |
위 예제 1️⃣에서 [property: string]: string
이 인덱스 시그니처이다.
- 키의 이름: 키의 위치만 표시. 타입 체커에서는 사용하지 않는다.
- 키의 타입: string이나 number 또는 symbol의 조합이어야 하지만 보통 string을 사용한다.
- 값의 타입: 어떤 것이든 가능하다.
그러나 인덱스 시그니처는 타입 체크 수행 시 아래와 같은 단점을 가진다.
- 잘못된 키를 포함한 모든 키를 허용 (name이 아닌 Name도 유효한 Rocket의 타입)
- 특정 키가 필요하지 않아. ({}도 유효한 Rocket의 타입)
- 키마다 다른 타입을 가질 수 없다. (thrust만 number일 수도 있는데 그렇게 사용할 수 없다.)
- 키는 무엇이든 가능하기 때문에 언어서비스(자동 완성 기능)이 제대로 동작하지 않는다.
이런 부정확한 부분을 개선하기 위해 2️⃣와 같이 인터페이스
로 작성한다.
그렇다면 인덱스 시그니처는 어떤 상황에서 사용해야 할까?
동적 데이터를 표현할 때, 런타임 때가지 객체의 속성을 알 수 없을 경우
예를 들어 CSV 파일처럼 행, 열에 이름이 있고 데이터 행을 열 이름과 값으로 매핑하는 객체로 나타내고 싶다면 인덱스 시그니처를 사용할 수 있다.
- 열 이름이 무엇인지 모른다 -> 인덱스 시그니처 사용
- 열 이름을 알고 있다 -> 미리 선언해 둔 타입 사용. 단, 런타임에 실제로 일치하지 않을 수도 있으므로 undefined를 추가해서 나타낼 수 있다.
- 어떤 타입에 가능한 필드가 제한되어 있다(e.g. 데이터 상에 키를 알고 있는데, 그것이 얼마나 있는지 알 수 없다.) -> 선택적 필드 또는 유니온 타입을 사용
string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있다면?
- Record를 사용한다. 키 타입에 유연성을 제공하는 제너릭 타입으로 string의 부분 집합을 사용할 수 있다.
- 매핑된 타입을 사용한다. 키마다 별도의 타입을 사용하게 해준다.
1 | //Record 사용 |
아이템 16 number 인덱스 시그니처보다는 Array, 튜플, ArrayLike를 사용하기
자바스크립트의 객체는 키/값 쌍의 모음인데, 키는 보통 문자열, 값은 어떤 것이든 될 수 있다. 그래서 숫자는 키로 사용할 수 없다. 배열의 경우, 분명히 객체인데 숫자 인덱스를 사용하는 것이 당연하다.
1 | x = [1, 2, 3]; |
배열에서 인덱스는 숫자타입이더라도 문자열로 변환되어 사용한다. 따라서 문자열 키를 사용해도 배열의 요소에 접근할 수 있다. Object.keys를 이용하여 배열의 키를 나열해보면 문자열로 구성되어 있음을 알 수 있다. 타입스크립트는 이러한 혼란을 바로 잡기 위해서 숫자 키를 허용하고, 문자열 키와 다른 것으로 인식한다.
1 | //for ~ in - 배열을 순회하는 방법이지만 좋은 방법은 아니다. |
타입이 불확실하면, for ~ in 루프는 for ~ of 루프에 비해 몇 배 느리다. 여기서 for ~ in, for ~ of는 둘 다 반복문인데, for ~ in은 반복가능한 객체를 순환하고, for ~ of는 배열 요소를 탐색한다. 두 가지가 다른점은 for ~ in의 대상이 되는 객체는 이터러블이 아니므로 객체에 대해서 for ~ of를 사용하면 에러가 발생한다는 것이고, for ~ in의 대상이 되는 배열의 경우 이터러블한 객체여서 for~of를 적용해도 에러가 발생하지 않는다. 다만 배열에 대해 for ~ in을 사용하면 객체의 키 값에 해당하는 인덱스가 나오고, for ~ of를 쓰면 해당요소가 나온다는 점이 다르다.
배열은 인덱스 시그니처가 number로 표현되어 있다면 입력한 값이 number여야 한다는 것을 의미하지만 실제 런타임에 사용되는 키는 string타입이다. 그러나 일반적으로 string 대신 number를 타입의 인덱스 시그니처로 사용할 이유는 많지 않다. 만약 숫자를 사용하여 인덱스할 항목을 지정한다면 Array 또는 튜플 타입을 사용한다. number를 인덱스로 쓰면 어떤 특별한 의미를 지닌다는 오해를 불러 일으킬 수 있다. 그리고, 어떤 길이를 가지는 배열과 비슷한 형태의 튜플을 사용하고 싶다면 타입스크립트에 있는 ArrayLike 타입을 사용한다. 단, ArrayLike를 사용해도 키는 여전히 문자열이다.
1 | const xs = [1, 2, 3]; |
아이템 17 변경 관련된 오류 방지를 위해 readonly 사용하기
아래 예제에서 1️⃣은 계산이 끝나면 원래 배열이 전부 비게 되는데도, 자바스크립트는 배열의 내용을 변경할 수 있기 때문에 타입스크립트에서 오류 없이 통과한다. 그래서 오류의 범위는 좁히기 위해 readonly 접근 제어자를 사용한다.
1 | // 1️⃣ |
위 예제에서 2️⃣의 readonly number[]는 타입이다. 특징은 아래와 같다.
- 배열의 요소를 읽을 수 있지만, 쓸 수는 없다.
- length를 읽을 수 있지만, 바꿀 수 없다.
- 배열을 변경하는 pop을 비롯한 다른 메서드를 호출할 수 없다.
- 변경 가능한 배열을 readonly 배열에 할당할 수 있다. 하지만 그 반대는 불가하다.
매개변수를 readonly로 선언하면 특징은 다음과 같다.
- 타입스크립트는 매개변수가 함수 내에서 변경이 일어나는지 체크한다.
- 호출하는 쪽에서 함수가 매개변수를 변경하지 않는다는 보장을 받게 된다.
- 호출하는 쪽에서 함수에 readonly 배열을 매개변수에 넣을 수 있다.
- 함수가 매개변수를 변경하지 않는다면, readonly로 선언한다.
- readonly는
얕게(shallow)
동작하므로 만약 객체의 readonly 배열이 있다면 객체 자체는 readonly가 아니다.
아이템 18 매핑된 타입을 사용하여 값을 동기화하기
다음은 타입 체커가 동작하도록 개선한 코드인데, 핵심은 매핑된 타입과 객체를 사용하는 것이다. [k in keyof ScatterProps]
는 타입 체커에게 REQUIRES_UPDATE가 ScatterProps와 동일한 속성을 가져야 한다는 정보를 제공한다.
1 | interface ScatterProps { |
References
[이펙티브 타입스크립트] 댄 밴더캄 지음