emahiro/b.log

Drastically Repeat Yourself !!!!

TypeScript で集合関係にある型の値の再代入をする

Overview

以下のような集合関係にある2つの型の間で値の再代入をしたいケースでハマってしまい、同僚のフロントエンドエンジニアに手伝ってもらって一定の方針で解決したのでその備忘録です。

type ObjA = {
    a: number;
    b: string;
}

type ObjB = {
    a: number;
    b: string;
    c: string;
}

この2つの関係はフィールドが集合関係にあり ObjB は ObjA として振る舞うこともできます。

調べたこと

存在しないフィールドに対して never 型にアサインしようとして型エラーになるケースがある

以下のような実装では値の再代入時に以下のエラーが発生します。

Type 'string | number' is not assignable to type 'never'.
  Type 'string' is not assignable to type 'never'.(

https://www.typescriptlang.org/play?ts=3.9.7&ssl=15&ssc=1&pln=16&pc=1#code/C4TwDgpgBA8gRgKwIJQLxQN4Cgq6gQwC4oA7AVwFs4IAnAbhzzmIGdgaBLEgcwYF8sWUJFiIAQmkyNcRUpWr1pUZlDaceDPFADGrdl15YBWbQHsSbAsXjJJGIgEYANMsIAiAExu+DMxeCuoggS6PaEHi7MbgDMbi66bgAs3gxYABQ2ENrAAHQA1hAgLGn4AJQELFBINDT4IAA8BSCmAGZBSAB8pTktpjQAovjaABZpUHloHVJaHG1pE1zK5dhaWvgA2nkAupJwm1uaeAJ8pal+LKYANhA5l6bcaQAG+FAAbviXZNAclQAk9jl8HxHqcgA

以下の実装だとエラーは発生しません。これはどうしてえらーが起きないのか現在調査中です。

https://www.typescriptlang.org/play?ts=3.9.7#code/C4TwDgpgBA8gRgKwIJQLxQN4Cgq6gQwC4oA7AVwFs4IAnAbhzzmPKtoYF8stRJZEAQmkyNcRUpWr1RUZhLbS8UAMbEAzsBoBLEgHNO3ZQHsSGgsXjJhGIgEYANLMIAmDg2OngT-giHobLo7MAMyOqgBEACzhbtwAFJYQysAAdADWECBqcfgAlARqUEg0NPggADwZIEYAZj5IAHy5KTVGNACi+MoAFnFQaWgNIkpadXEDOrL52EpK+ADaaQC6wnCLSwxKXBy5DFgeakYANhApR0a6cQAG+FAAbvhHZNBahQAkNin4HFe7QA

ObjA と ObjB の構造の違いですが、前者のケースでは最初から key の方が number | string になります。

解決策

1 - オブジェクトにアクセスするための field を用意する

アクセス用の [key: string] ... の フィールドを埋め込むとエラーを解消できます(= field を指定して異なる方でも値を代入できます)

type ObjA = {
    a: number;
    b: string;

    [key: string]: string|number;
}

type ObjB = {
    a: number;
    b: string;
    c: string;
    
    [key:string]: string|number;
}

ref: https://www.typescriptlang.org/play?ts=3.9.7#code/C4TwDgpgBAGlC8UDeAoK6oEMBcUB2ArgLYBGEATmhiboaRVegNoDWuAzsOQJZ4DmAXVrEy5AD6ce-FAF8UoSFACaCZIywcuvPuppRJ29a01TBw+uIPS5KAMYB7PJygAPXHESoMGqAEYANLq4AbIodo7OILgqnuo4UABEvgmB3npJKaHhTvYANhAAdLn2fAAUCWQAZvbkECmuAJQA3GGlAPIkAFYQtsAFLBAg7KUuDVjsUACC5OSYIAA8AyD2lbAAfA0F1eQAopi2ABalLAhrat4urAKqIFctMs1AA

ただし Runtime での型を確定するためには typeof で再代入先のフィールドと型が同じかを確認しないといけないです。

以下のような number 型なのに型が変更されて値が再代入されてしまい、型の意味がなくなります。

https://www.typescriptlang.org/play?ts=3.9.7#code/C4TwDgpgBAGlC8UDeAoK6oEMBcUB2ArgLYBGEATmhiboaRVegNoDWuAzsOQJZ4DmAXVrEy5AD6ce-FAF8UoSFACaCZIywcuvPuppRJ29a01TBw+uIPS5KAMYB7PJygAPXHESoMGqAEYANLq4AbIodo7OILgqnuo4UABEvgmB3npJKaHhTvYANhAAdLn2fAAUCWQAZvbkECmuAJQA3GGlAPIkAFYQtsAFLBAg7KUuDVjsUACC5OSYIAA8AyD2lbAAfA0F1eQAopi2ABalLAhrat4urAKqIFctMs1hDjn5RSXlmJXAFPWjQA

2 - 再代入する時に一度展開してから field に対応する値を再度代入する。

以下のように値に再代入する前に一度展開して全てのフィールドを埋め、フィールドの型を確定した状態で代入します。

type X = {
    a: number
    b: string
}
type Y = {
    a: number
    b: string
    c: string
}

let x: X = {
    a: 1,
    b: "1",
}

const y: Y = {
    a: 2,
    b: "2",
    c: "3"
}

(Object.keys(x) as Array<keyof X>).forEach(k => {
    x = {
        ...x,
        [k]: y[k]
    }
});

https://www.typescriptlang.org/play?ts=3.9.7&ssl=10&ssc=1&pln=11&pc=13#code/C4TwDgpgBAGlC8UDeAoK6oEMBcUB2ArgLYBGEATmhibgM7DkCWeA5igL4qiRQCaCyKuhz5iZShig0o9JqyFQAxnQbM2nFABsIwKAA9ccRKkkiAjABoF0gERmbVjYoD2eelBC5+xhSIBMVpK2fg4KylA2AMw2HCgoLm7O2gB0ms4sABQ2ZABmzuQQDvoAlADccRkA8iQAVhCKwMkA1hAgtBl6xVi0UACC5OSYIAA8LSDOObAAfMXJeeQAopiKABYZTQhTgpJ6AiaSGMlHeoEH6ADaTQC6uCCXVwqc7GVxCbRJEKnpWZg5wBRFTpAA

この手法では大元の定義されている型の中であえて型情報を削ってしまう accessor を実装することなく局所的に解決します。先に値を全てスプレッド構文で埋めてしまい、どのフィールドがどの型なのかの情報を入れているので集合関係にあるフィールドの値を入れ込むことができます。

ただしこの場合でも上記のような型情報が失われて別の Type の値を埋め込んでしまうことができるという意図しない挙動が発生するので、このケースでも typeof での型チェックは必要です。

またこの方法は for で再代入するごとに展開して入れ直す = 新しくオブジェクトを alloc して更新したいオブジェクトに入れ直すことになるので、メモリを無駄に消費している実装になりますのでこのことに留意する必要はあります。

考察

ある Map において任意の property の型が不明な場合、同じような構造を持つ(or 集合関係にある構造をもつ)型の値で埋めようとしても、別の型として認識されてしまい、例え代入先の property の型が一致するような場合でも割り当て不可のエラーが発生する模様です。

Google で検索しても割り当てする側の型の情報だけでは割り当てできない、というエラーがいくつか出てきます。

まとめ

TypeScript とても難しい。

単なる備忘録であり半分メモなので何かわかったらまた追記します。

追記

20211115

こんな感じで assignable で値を入れ替えるとよさそう、と教えてもらいました。
https://www.typescriptlang.org/play?target=99&ts=3.9.7&ssl=31&ssc=1&pln=32&pc=1#code/C4TwDgpgBA8gRgKwIJQLxQN4Cgq6gQwC4oA7AVwFs4IAnAbhzzmIGdgaBLEgcwYF8sWUJFiIAQmkyNcRUpWr1pUZlDaceDPFADGrdl15YBWADYRgBYvGSSMlqAEYANMuIAiAExuofBmYsq1hLodrIeLipuAMxuLrpQbgAs3r6CAPRpUAAU1hDawAB0ANYQICxZ+ACUBCxQSDQ0+CAAPCUgAPYAZqLIAHyVBZ3tNACi+NoAFllQRWi9UhlaUBzdWbNcytXYi0t4+ADaRQC6knCHRww7eAKLfJUMWNrtJGxQZGAAJvjAEADqHMAJkgWCwONwSPg4GZJM0lAAVKAQAAePxIH1qACU8sMPs01AYXOQqLQoAAfVT6Hi9JxKACqiJREDRmOxNFx+J4hPkJPJHO4vSwvSySmA+Bo3HMxDhNK0LHaZBo2ggxFpWEqUrmUi0NHMCpIPTyhU6NHaFBGJH0EHKSi0uXyBSZlvKovF5gGFHwYCyWUOpRcADd8CYyBAjtVUPNsLtditsm1lvq5QqlVAAGSpqDCCBdKCB4PQVCFzPgbPdJOKiC+kBhrXRus64B6qBVlzlpVVo4XG3RgR17W6mj6lu5oMhrt1u5Kar4Wpw-gPfCSd5fH7-QHA0HgyFmCoRe6CR7POVmAomdrcLIAA0XeZDy1qABIMPgCvg+Jf7kA