[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'

Learn about Record and Pick among the utility types provided by Typescript. and the difference between using Object.keys(..) and Object.entries(..).

Utility type Record

the following Typescript code throws a type error.

code#01
export class Morphheme { 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[]'

the intention of [code#01] was to define the same property name and type for object literals as for class Morpheme.

here, the intent of same property name and type is

  • a) property tokens should be of type string[].
  • b) property elapsed should be of type number.

however, if you use the utility type Record in [code#01], the meaning of a) and b) is not the same.

Record utility class
Record<keyof Morpheme, Morpheme[keyof Morpheme]>
  • the properties need only be "tokens" and "elapsed" for keyof T.
  • each property can be of type "string[]" or "number" - T[keyof T].

this means that in the following [code#02], ra, rb, and rc all pass type checking.

CODE#02
type AsDto = Record<keyof Morphem, MorphemeDto[keyof Morpheme]>; const ra:AsDto = { tokens: 23, // eh? elapsed: 45 } const rb:AsDto = { tokens: ['b', 'k'], elapsed: ['a'] // huh? } const rc:AsDto = { tokens: 23, // Oh my... elapsed: ['a'] // Oh no... }
  • tokens can be number | string[].
  • elapsed can be number | string[].

Utility type Pick

to limit the properties to a specific type (morphology) and its type, use the utility type Pick as follows.

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' }

the utility type Pick is equivalent to the type definition below.

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

the generic type T corresponds to Morpheme in [code#03].

k extends keyof T` means the properties of type T (tokens, elapsed).

and [P in K] constrains the type to be T[P] for each such property.

here P corresponds to either "tokens" or "elapsed".

  • for "tokens", Morpheme["tokens"], which means the type string[].
  • morpheme["elapsed"] for "elapsed", i.e., type number.
  • note that "tokens" and "elapsed" are not string values, but are themselves types.

Object.keys(...), Object.entries(...)

here's a point to keep in mind about Typescript's type checking.

we've defined a function handle that converts the dto to a class, like this

CODE#05
type AsPick = Pick<Morpheme, 'tokens' | 'elapsed'> const handle = (dto: AsPick) => { return new Morphheme(dto.tokens, dto.elapsed); }

now, if we call the function handle with the following object as an argument, will we get a type error?

CODE#06
const dtoFromServer = { tokens: ['one', 'two'], elapsed: 300, ngram: 2 }; handle(dtoFromServer)
  • dtoFromServer is given the type {tokens: string[], elapsed: number, ngram: number } by type inference.
  • the dtoFromServer has an extra property ngram (extra property)

no type error is raised in [code#06].

however, if you pass it as an object literal like this, it shows a type error.

CODE#07
handle({ tokens: ['one', 'two'], elapsed: 300, ngram: 2 ^^^^^ // Object literal may only specify known properties // 'ngram' does not exist in type 'AsPick'. })

Due to the nature of the Javascript language, which is controlled by Typescript, [CODE#06] the variable dtoFromServer may contain additional properties. the most common cases are

CODE#08(javascript)
const dto = { ... }; if( isDownloadable(data) ) { dto.enableDownload = true; } return dto;
  • suddenly we have an enableDownload property. - Object Pollution

[code#06] would realistically look like this

CODE#09
const dtoFromServer = await analyze("Some texts are... ");

Javascript is seriously dynamic, so you can think of type checking in Typescript in the following sense.

At the very least, let's have this.

the type Pick<Morpheme, 'tokens' | 'elapsed'> means that the object passed as an argument to the function must contain at least

  • "tokens": string[]
  • "elapsed": number

means it should be there (or "at least this should be there")

there may be other properties inside the object, but as long as the given property (and method) exists, no runtime error will be thrown because of the type (handle) inside the function.

in the case of [CODE#07], the compiler can be assured that there is no object pollution because the object literal is created and passed directly to the function.

so, unlike [CODE#06] and [CODE#09], we have stronger type checking.

now we iterate over the properties using Object.keys.

CODE#10
type AsPick = Pick<Morpheme, 'tokens' | 'elapsed'> const handle = (dto: AsPick) => { Object.keys(dto).forEach((key) => { console.log(key, dto[key]); ^^^^^^^^ }) }
  • i see this a lot.

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'.

You can't be sure that the array returned by Object.keys contains only "tokens" and "elapsed".

it could have been passed through a bunch of functions, like [CODE#08], and gotten a strange property somewhere along the way.

so we can't be sure that dto[key] is necessarily of type string[] or that it's of type number.

in this case, we use the following trick

CODE#11
const handle = (dto: AsPick) => { Object.keys(dto).forEach((key) => { console.log(key, dto[key as keyof Morpheme]); }) }
  • take the variable key to be of type "tokens" or "elapsed": as keyof T

the type of key is string, but is forced to be typed as "tokens" | "elapsed".

"Let's say yes. don't question it."

the same limitation exists for Object.entries.

suppose we have a type that represents subjects and scores, like this

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) }

the function sumOfScore accumulates the number of subjects and the total score.

from the second argument of recuce, we know that score is of type number (because all its properties are of type number)

however, the type checking would fail if we received the following values from the server

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" }
  • one day, the server puts in a date property.

Typescript Utility Type

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