[typescript Utility Types][Record][Pick] Type 'A' is not assignable to type 'B'

Typescript Utility Type

[typescript Utility Types][Record][Pick] Type 'A' is not assignable to type 'B'

Typescript에서 제공하는 Utility type 중에서 RecordPick에 대해서 알아봄. 그리고 Object.keys(..)와 Object.entries(..)를 사용할 때 주의할 점을 다릅니다.

Utility type Record

아래의 Typescript 코드는 타입 오류가 발생한다.

code#01
export 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 class
Record<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#03
type 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#04
type 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#05
type AsPick = Pick<Morpheme, 'tokens' | 'elapsed'> const handle = (dto: AsPick) => { return new Morpheme(dto.tokens, dto.elapsed); }

이제 다음과 같은 객체를 인자로 전달해서 함수 handle을 호출하면 타입 오류가 발생할까?

CODE#06
const 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#07
handle({ 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#11
const 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#12
type 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#13
const scoresFromServer = { math: 43, eng: 89, kor: 76, date: '2024-10-12' } console.log(sumOfScore(scoresFromServer)) // prints { "lectures": 4, "total": "2082024-10-12" }
  • 어느날 서버에서 date 프로퍼티를 넣어줍니다.

Typescript Utility Type

[typescript Utility Types][Record][Pick] Type 'A' is not assignable to type 'B'