Kitsune Gadget

気になったことをつらつらと

TypeScriptで特定のプロパティがユニオンの全要素を持つオブジェクト配列の検出

まず初めにこちらの記事を目にしました。

qiita.com

ユニオンの全要素を満たす配列の検出ができるなら、 プロパティにユニオン値をもつオブジェクト配列に対しても、同じようなことができないかと考えました。

今回の例ではプロパティに Fruit のユニオンを持たせて、FruitInfo型でFruitそれぞれに対しての情報を定義したいとします。

type Fruit = 'apple' | 'orange' | 'banana';
type FruitInfo = {
    fruit: Fruit;
    stock: number;
    isDisplay: boolean;
}

つくったもの

function allObjElements<T, K extends keyof T>(): <A extends T[]>(
  arr: A
) => Exclude<T[K], A[number][K]> extends never
  ? A
  : [{ notFound: Exclude<T[K], A[number][K]> }] {
  return (a) => a as any;
}

type Fruit = 'apple' | 'orange' | 'banana';
type FruitInfo = {
  fruit: Fruit;
  stock: number;
  isDisplay: boolean;
}

const fruitInfo: FruitInfo[] = allObjElements<FruitInfo, 'fruit'>()([
  {
    fruit: 'apple',
    stock: 50,
    isDisplay: true,
  }, 
  {
    fruit: 'orange',
    stock: 30,
    isDisplay: false,
  }
]);
// Type '[{ notFound: "banana"; }]' is not assignable to type 'FruitInfo[]'.
// Type '{ notFound: "banana"; }' is missing the following properties from type 'FruitInfo': fruit, stock, isDisplay

これによって、ユニオンをもつプロパティのキー名を指定すると、オブジェクト配列が持つ特定のプロパティがユニオンをすべて満たしてるか検出することができました。

ちなみにユニオン以外、プリミティブ型や配列を持つプロパティを指定すると、number, string等の型の名前そのものを精査するようになるため、条件を満たせません。

同様にany, unknownneverでも条件を満たすことができなくなります。アサーションで回避できてしまいますが、そもそも精査したい情報でこれらを使うことはまずないでしょう。

ただし、booleanだけは、true | falseのユニオンとして識別されるため、少なくとも両方が含まれていないといけないという条件を与えることができます。特殊な使い方ができそうですがあくまで独自定義したユニオンに対して使ったほうがよさそうです。

satisfies による制限

型だけではひとつ問題がありました。入力する配列に対してのTの拡張型ではプロパティの制限ができません。 さらに追加のプロパティ入れた場合でも最終的にアノテーションの型として解釈されます。この場合、型には存在しないので参照しようとるすとエラーが発生しますが、実オブジェクトそのものには値を含んでいるという状況です。

解決方法として入力を satisfiesで検証させることです。version 4.9 から satisfiesオペレーターが追加されています。

www.typescriptlang.org

これを利用して入力式が型を満たしていることを確認できます。

const fruitInfo: FruitInfo[] = allObjElements<FruitInfo, 'fruit'>()([
  {
    fruit: 'apple',
    stock: 50,
    isDisplay: true,
    undefinedKey: 0, // Object literal may only specify known properties, and 'undefinedKey' does not exist in type 'FruitInfo'.
  }, 
  {
    fruit: 'orange',
    stock: 30,
    isDisplay: false,
  },
  {
    fruit: 'banana',
    stock: 70,
    isDisplay: false,
  }
] satisfies FruitInfo[]); // satisfies で検証

FruitInfoを3回も書かなくてはならないですが、型を安全に扱いたい代償ですね。

型の説明

function allObjElements<T, K extends keyof T>(): <A extends T[]>(
  arr: A
) => Exclude<T[K], A[number][K]> extends never
  ? A
  : [{ notFound: Exclude<T[K], A[number][K]> }] {
  return (a) => a as any;
}
  • T: 入力したいオブジェクトの型
  • K: オブジェクトに含まれる検出したいキー名
  • A: オブジェクト配列

参考記事とほぼ同じ仕組みです。

A[number][K]はオブジェクト配列すべてからキーKにある型を取得します。(これはユニオンになります。)

Exclude<T[K], A[number][K]>neverになるとT[K]のユニオン定義をA[number][K]のユニオンがすべて満たしていることになり、Aをそのまま返します。

never以外では、[{ notFound: Exclude<U, A[number][K]> }]として別オブジェクト型として返すようにします。

型の引数処理について

A = T[]としてしまうと、型が完全なTとして処理されるため、予め定義してある型そのものの情報しか得られなくなります。通常この挙動は問題ありませんが、入力の内容そのものを型として落とし込むには型の直接指定をしない方法が必要になります。

A extends T[]ではAは渡される入力そのものの型として処理し、型Tのプロパティを含むことを保障します。ただし拡張も許してしまうため、入力されるオブジェクトに対して追加のプロパティがあってもエラーにはなりません。 先ほどの記述にあったようにsatisfiesに検証させることでこれを解決しています。

おまけ:代入型を作ってみる

前述の方式を関数型と呼ぶことにします。関数型なしにも検出できないか作ってみました。 結論としてsatisfiesが追加されたことによってこの方式が可能になったと言えます。

type SatisfiedElements<U, A extends readonly U[]> =
  Exclude<U, A[number]> extends never ? A : { notFound: Exclude<U, A[number]> };

type Fruit = 'apple' | 'orange' | 'banana';

const initFruits1: Fruit[] = ['apple'];
const initFruits2 = ['apple'] satisfies Fruit[];
const initFruits3 = ['apple', 'orange', 'banana'] satisfies Fruit[];
const initFruits4 = ['apple'] as const;
const initFruits5 = ['apple', 'orange', 'banana'] as const; // Fruit 以外の値を入れるミスがあり得る
const initFruits6 = ['apple', 'orange', 'banana'] as const satisfies Readonly<Fruit[]>;

const fruits1: SatisfiedElements<Fruit, typeof initFruits1> = initFruits1;
// const fruits1: Fruit[]

const fruits2: SatisfiedElements<Fruit, typeof initFruits2> = initFruits2;
// Property 'notFound' is missing in type '"apple"[]' but required in type '{ notFound: "orange" | "banana"; }'.

const fruits3: SatisfiedElements<Fruit, typeof initFruits3> = initFruits3;
// const fruits3: ("apple" | "orange" | "banana")[]

const fruits4: SatisfiedElements<Fruit, typeof initFruits4> = initFruits4;
// Property 'notFound' is missing in type 'readonly ["apple"]' but required in type '{ notFound: "orange" | "banana"; }'.

const fruits5: SatisfiedElements<Fruit, typeof initFruits5> = initFruits5;
// const fruits5: readonly ["apple", "orange", "banana"]

const fruits6: SatisfiedElements<Fruit, typeof initFruits6> = initFruits6;
// const fruits6: readonly ["apple", "orange", "banana"]

変数が余分に1つ増えますが、これを代入型と呼ぶことにしましょう。

fruit1だけは先にFruit[]と定義してしまった配列となって検出することが出来ません。

const fruits = ['apple']とすると型は自動的に string[]に推論されますが、satisfiesを与えると、指定の型を満たすことを確保させつつもアノテーションとは違い、型付与をしません。そのため、("apple" | "orange" | "banana")[]型というものが配列情報から生まれています。

配列に存在しない値はユニオンに含まれません。'banana'を無くせば("apple" | "orange")[]型となるのです。 これがオブジェクトになると、({fruit: "apple", value: "ringo"} | {fruit: "orange", value: "mikan"})[]のように分離したユニオン型に推論されます。

const fruits: Fruit[] = ['apple']のようにアノテーションを追加する場合、型は常にFruit[]すなわち ("apple" | "orange" | "banana")[]という状態になるため、実配列そのものの情報にできません。 最初の関数型でも配列からの情報で独自の型が構成されて使われています。

fruit1以外が上手くいっているのは、このように型を配列そのものから作っていることが理由です。

as constでは、readonly ["apple", "orange", "banana"]という固定の型を作ります。型としてはユニオンと同じように扱えますが、配列としては位置が固定されている点が違います。fruit5[0]'apple'という文字列しか代入を受け付けません。 ただし、const fruits: Fruit[] = ['apple', 'orange', 'banana'] as constのようにしてもアノテーションの型で上書きされるため注意してください。

ちなみに返されるそれぞれの型は表面上違うものになっていますが、Fruit[]とは互換性があるため、アサーションFruit[]の引数できちんと扱えます。

キーの制限も予めsatisfiesされているため正しく行うことができます。

const initFruitInfo = [
  {
    fruit: 'apple',
    stock: 50,
    isDisplay: true,
    undefinedKey: 0, // Object literal may only specify known properties, and 'undefinedKey' does not exist in type 'FruitInfo'.
  }, 
  {
    fruit: 'orange',
    stock: 30,
    isDisplay: false,
  }
] satisfies FruitInfo[];

ユニオンの精査もできています。

type SatisfiedObjArray<T, K extends keyof T, A extends readonly T[]> =
  Exclude<T[K], A[number][K]> extends never
    ? A
    : [{ notFound: Exclude<T[K], A[number][K]> }];

const fruitInfo: SatisfiedObjArray<FruitInfo, 'fruit', typeof initFruitInfo> = initFruitInfo;
// Type '({ fruit: "apple"; stock: 50; isDisplay: true; } | { fruit: "orange"; stock: 30; isDisplay: false; })[]' is not assignable to type '[{ notFound: "banana"; }]'.

代入型ではas constで定義したものを代入する場合に型を保持できたのはこの方法だけです。

const initFruits6 = ['apple', 'orange', 'banana'] as const satisfies Readonly<Fruit[]>
const fruits6: SatisfiedElements<Fruit, typeof initFruits6> = initFruits6
// const fruits6: readonly ["apple", "orange", "banana"]

オブジェクト配列でもこれをすることができます。

type FruitInfo = {
    fruit: Fruit;
    stock: number;
    isDisplay: boolean;
    nest: {
      value: string
    }
}

const initFruitInfo = [
  {
    fruit: 'apple',
    stock: 50,
    isDisplay: true,
    nest: {
      value: 'ringo'
    }
  }, 
  {
    fruit: 'orange',
    stock: 30,
    isDisplay: false,
    nest: {
      value: 'mikan'
    }
  }
] as const satisfies Readonly<FruitInfo[]>;
/*
const initTest: readonly [{
    readonly fruit: "apple";
    readonly stock: 50,
    readonly isDisplay: true,
    readonly nest: {
        readonly value: "ringo";
    };
}, {
    readonly fruit: "orange";
    readonly stock: 30,
    readonly isDisplay: false,
    readonly nest: {
        readonly value: "mikan";
    };
}]
*/

const fruitInfo: SatisfiedObjArray<FruitInfo, 'fruit', typeof initFruitInfo> = initFruitInfo;
// The type 'readonly [{ readonly fruit: "apple"; readonly stock: 50; readonly isDisplay: true; readonly nest: { readonly value: "ringo"; }; }, { readonly fruit: "orange"; readonly stock: 30; readonly isDisplay: false; readonly nest: { readonly value: "mikan"; }; }]' is 'readonly' and cannot be assigned to the mutable type '[{ notFound: "banana"; }]'.

satisfiesは 型に含まれるreadonlyプロパティまでは検査してくれないようです。すなわち、代入型では全体をreadonlyに する/しない のどちらかしかできません。一部のみのreadonlyを扱うには別でアサーションアノテーション付き代入を行う必要があります。

関数型でも同様なことは可能ですが少し違います。 渡す配列をas constしたとしても変数に入る際にアノテーションで型がFruitInfo[]になるため、ネストもreadonlyではなくなります。

const fruitInfo: FruitInfo[] = allObjElements<FruitInfo, 'fruit'>()([ // ただの FruitInfo[] 型
  {
    fruit: 'apple',
    stock: 50,
    isDisplay: true,
  }, 
  {
    fruit: 'orange',
    stock: 30,
    isDisplay: false,
  },
  {
    fruit: 'banana',
    stock: 70,
    isDisplay: false,
  }
] as const satisfies Readonly<FruitInfo[]>);

as constと同じことをするには再帰的なReadonlyで型を指定する必要があります。 そうすると型を後指定するため、入力をas constにする必要が特にありません。

type DeepReadonly<T> = keyof T extends never ? T : {readonly [P in keyof T]: DeepReadonly<T[P]>};
const fruitInfo: DeepReadonly<FruitInfo[]> = allObjElements<FruitInfo, 'fruit'>()([
  {
    fruit: 'apple',
    stock: 50,
    isDisplay: true,
  }, 
  {
    fruit: 'orange',
    stock: 30,
    isDisplay: false,
  },
  {
    fruit: 'banana',
    stock: 70,
    isDisplay: false,
  }
] satisfies FruitInfo[]);
/*
const fruitInfo: readonly {
    readonly fruit: Fruit;
    readonly stock: number,
    readonly isDisplay: boolean,
}[]
*/

関数型のreadonly化には少し書き足す必要がありますが、最終的にアノテーションして型を付与できるので柔軟性が高いと言えます。

一部のみのreadonlyにしたいなら型で予め定義しておいたほうが良いでしょう。

type FruitInfo = {
    readonly fruit: Fruit;
    stock: number;
    isDisplay: boolean;
}

まとめ

「特定のプロパティがユニオンの全要素を持つオブジェクト配列の検出」ということでしたが、これらは version 4.9で追加されたsatisfiesオペレーターが無ければ達成できないものでした。

関数型はアノテーションで最終的な型を設定するため、型を固定したい場合にはやはりこちらを使うほうが便利でしょう。

参考記事は5年前のものですが、そこで触れていたように自動推論では型引数の一部省略はできず、 全て書く もしくは 全て省略する のどちらかというのは変わっていませんA extends T[] = T[]とすれば省略できるパターンもありますが。この指定をしてしまうと結局入力がT[]になってしまいます。 欲しいのは部分的な引数を書きつつ、型のデフォルトが無い場合に入力から型を推論してくれる機能なのです。

それにしても、型は便利だけど難しいと再認識する結果になりました。

今回のコードはPlaygroundでも見れるようにしました。 Playground