[typescript Utility Types][Record][Pick] Type 'A' is not assignable to type 'B'
Typescript Utility Type
Typescript에서 제공하는 Utility type 중에서 Record와 Pick에 대해서 알아봄. 그리고 Object.keys(..)와 Object.entries(..)를 사용할 때 주의할 점을 다릅니다.
Utility type Record
아래의 Typescript 코드는 타입 오류가 발생한다.
code#01export class Morpheme {
tokens: string[];
elapsed: number;
constructor(tokens: string[], elapsed: number) {
this.tokens = tokens;
this.elapsed = elapsed;
}
static fromDto(
dto: Record<keyof Morpheme, Morpheme[keyof Morpheme]>,
) {
return new Morpheme(dto.tokens, dto.elapsed); // error at "dto.tokens"
^^^^^^^^^^
}
}
Argument of type 'number | string[]' is not assignable to parameter of type 'string[]'. Type 'number' is not assignable to type 'string[]'
[code#01]의 의도는 객체 리터럴에 클래스 Morpheme
과 동일한 property 이름과 타입을 정의하려고 한 것이다.
여기서 동일한 property 이름과 타입 의 의도는 다음과 같다.
- a) property tokens 는
string[]
타입이어야 함. - b) property elapsed 는
number
타입이어야 함.
그런데 [code#01]에서 유틸리티 타입인 Record
를 쓰면 a)와 b)의 의미가 아니게 된다.
Record utility classRecord<keyof Morphem, Morpheme[keyof Morpheme]>
- property는 "tokens"와 "elapsed"만 있으면 됨
keyof T
. - 각각의 property들은 타입이 "string[]" 이거나 "number" 이면 됨 -
T[keyof T]
.
즉 다음의 [code#02]에서 ra, rb, rc 모두 타입 검증이 통과한다.
CODE#02
type AsDto = Record<keyof Morphem, MorphemeDto[keyof Morpheme]>;
const ra:AsDto = {
tokens: 23, // 어?
elapsed: 45
}
const rb:AsDto = {
tokens: ['b', 'k'],
elapsed: ['a'] // 응?
}
const rc:AsDto = {
tokens: 23, // 아이고..
elapsed: ['a'] // 이런...
}
- tokens 는
number | string[]
이면 된다. - elapsed 는
number | strinng[]
이면 된다.
Utility type Pick
특정 타입(Morpheme)의 property 들과 그 타입까지 한정하려면 다음과 같이 utility type Pick 을 사용한다.
CODE#03type AsPick = Pick<Morpheme, 'tokens' | 'elapsed'>
const pa:AsPick = {
tokens: 23,
^^^^^^ // Type 'number' is not assignable to type 'string[]'
elapsed: 45
}
const pb:AsPick = {
tokens: ['b', 'k'],
elapsed: ['a']
^^^^^^^ // Type 'string[]' is not assignable to type 'number'
}
const pc:AsPick = {
tokens: 23,
^^^^^^ // Type 'number' is not assignable to type 'string[]'
elapsed: ['a']
^^^^^^^ // Type 'string[]' is not assignable to type 'number'
}
utility type Pick 은 아래의 타입 정의와 동일하다.
CODE#04type Pick<T, K extends keyof T> = { [P in K]: T[P]; }
- From T, pick a set of properties whose keys are in the union K
제네릭 타입 T
는 [code#03]에서 Morpheme
에 해당한다.
K extends keyof T
는 타입 T의 property들(tokens, elapsed)을 의미한다.
그리고 [P in K]
는 그러한 property들마다 타입을 T[P]
로 한정한다.
여기서의 P
는 "tokens" 또는 "elapsed"에 해당한다.
- "tokens"에 대해서 Morpheme["tokens"], 즉 타입 string[] 을 의미함.
- "elapsed"에 대해서 Morpheme["elapsed"], 즉 타입 number 를 의미함.
- 여기서 "tokens"와 "elapsed"는 문자열 값이 아니라 그 자체로 타입 이다.
Object.keys(...), Object.entries(...)
여기서 Typescript의 type checking 에 대해서 염두에 둬야 할 지점이 있다.
다음과 같이 dto 를 클래스로 변환하는 함수 handle
을 정의했다.
CODE#05type AsPick = Pick<Morpheme, 'tokens' | 'elapsed'>
const handle = (dto: AsPick) => {
return new Morpheme(dto.tokens, dto.elapsed);
}
이제 다음과 같은 객체를 인자로 전달해서 함수 handle
을 호출하면 타입 오류가 발생할까?
CODE#06const dtoFromServer = {
tokens: ['one', 'two'],
elapsed: 300,
ngram: 2
};
handle(dtoFromServer)
- dtoFromServer 는 타입 추론에 의해서
{tokens: string[], elapsed: number, ngram: number }
타입이 부여됨. - dtoFromServer 에는 property
ngram
이 부가적으로 존재한다.(extra property)
[code#06]에서는 타입 오류가 발생하지 않는다.
그런데 다음과 같이 객체 리터럴로 전달하면 타입 오류를 보여준다.
CODE#07handle({
tokens: ['one', 'two'],
elapsed: 300,
ngram: 2
^^^^^
// Object literal may only specify known properties
// 'ngram' does not exist in type 'AsPick'.
})
Typescript 가 통제하는 Javascript 언어의 특성 때문인데 [CODE#06] 에서는 변수 dtoFromServer 안에 부가적인 property가 들어있을 수 있다. 가장 흔한게 아래와 같은 경우이다.
CODE#08(javascript)const dto = { ... };
if( isDownloadable(data) ) {
dto.enableDownload = true;
}
return dto;
- 갑자기 enableDownload property 가 생겼습니다. - 객체 오염
[code#06]은 현실적으로 아래와 같이 생겼을 것이다.
CODE#09
const dtoFromServer = await analyize("Some texts are... ");
Javascript는 심각하게 동적이어서 Typescript의 type checking은 아래와 같은 의미로 생각하면 좋다.
최소한 이것만은 갖고 있자.
타입 Pick<Morpheme, 'tokens' | 'elapsed'>
은 함수의 인자로 전달된 객체 안에 최소한
- "tokens": string[]
- "elapsed": number
는 있어야 한다는 뜻이다.(또는 "최소한 이거는 분명히 있음")
그 외에 객체 안에 다른 속성이 더 있을 수 있지만, 주어진 property(와 메소드) 만 존재하면 그 함수 내에서는(handle) 타입때문에 런타임 오류가 발생하지는 않을테니까.
[CODE#07]의 경우는 객체 리터럴을 생성하면서 곧바로 함수에 전달하기 때문에 객체 오염이 없음을 컴파일러가 확신할 수 있다.
그래서 [CODE#06]이나 [CODE#09]와 다르게 type checking 을 더 강하게 하고 있다.
이제 Object.keys
를 이용해서 property들에 대해 반복문을 돌려봄.
CODE#10
type AsPick = Pick<Morpheme, 'tokens' | 'elapsed'>
const handle = (dto: AsPick) => {
Object.keys(dto).forEach((key) => {
console.log(key, dto[key]);
^^^^^^^^
})
}
- 이거 엄청 자주 만난다.
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'AsPick'. No index signature with a parameter of type 'string' was found on type 'AsPick'.
Object.keys가 반환하는 배열 안에는 "tokens"와 "elapsed"만 있다고 장담할 수 없다.
[CODE#08]과 같이 수많은 함수를 거쳐 오면서 어디선가 이상한 property가 묻었을 수 있다.
그래서 dto[key]
가 반드시 타입 string[]
이거나 타입 number
라고 확신할 수가 없다.
이런 경우 아래와 같이 꼼수를 쓴다.
CODE#11const handle = (dto: AsPick) => {
Object.keys(dto).forEach((key) => {
console.log(key, dto[key as keyof Morpheme]);
})
}
- 변수 key의 타입은 "tokens" 이거나 "elapsed"라고 받아들여라.
as keyof T
key의 타입은 string
인데 강제로 타입을 "tokens" | "elapsed"
로 지정한 것.
"그렇다고 치겠음. 따지지 마셈"의 의미다.
Object.entries
역시 이런 한계는 똑같이 존재한다.
다음과 같이 과목과 점수를 나타내는 타입이 있다고 해보겠음.
CODE#12type Score = {
math: number,
eng: number,
kor: number;
}
const sumOfScore = (score: Score) => {
const acc = {lectures: 0, total: 0 };
return Object.entries(score).reduce((acc, [lecture, score]) => {
acc.total += score;
acc.lectures++;
return acc;
},acc)
}
함수 sumOfScore 는 과목 갯수와 총점을 누적해서 구하고 있다.
recuce 의 두 번째 인자를 보면 score
는 number 타입을 확신한다.(모든 property 들이 타입 number 이기 때문)
하지만 서버에서 다음과 같은 값을 받아왔다면 type checking은 실패한다.
CODE#13const scoresFromServer = {
math: 43,
eng: 89,
kor: 76,
date: '2024-10-12'
}
console.log(sumOfScore(scoresFromServer))
// prints { "lectures": 4, "total": "2082024-10-12" }
- 어느날 서버에서
date
프로퍼티를 넣어줍니다.