🎅

部分型って"拡張型"じゃない?集合論で捉えてみる

2023/12/11に公開

TypeScriptの部分型

TypeScriptの部分型は、構造的部分型というものが採用されています。
「この型はある型の部分型である」というように宣言[1]するのではなく、型の構造によって部分型かどうかの判定がされます。
(構造的部分型が採用されていることから、一見コード上で関係のなさそうな型同士に部分型の関係が発生することもあります。)

部分型の定義

Sが型Tの部分型であるとは、S型の値がT型の値でもあることです。

type T = { str: string }
type S = { str: string, num: number }

const tObj: T = {str:'t'}
const sObj: S = {str:'s', num:314}

const t: T = sObj
const s: S = tObj // Property 'num' is missing in type 'T' but required in type 'S'.(2741)

上のコードでは、T型の変数にS型のオブジェクトを代入することができています。このようなとき、S型はT型の部分型であるといいます。

部分型というより拡張型?

先のコード例で、型Tstring型のstrというプロパティが存在することを要求しているのに対し、型Sは追加でnumber型のnumというプロパティの存在も要求していました。
 
T型に含まれる必須プロパティはS型にも必ず含まれているのに対し、S型の必須プロパティは必ずしもT型には含まれません。
集合論において、「SがTの部分集合である」とは、「集合Sに含まれる要素がすべて集合Tに含まれること」でしたから、S型はT型の部分型というよりは拡張型の方が相応しいのでは?と疑問に感じるかもしれません。
型引数の制約として、型Tが型Uの部分型であることを課すときにT extends Uと書きますが、このextendsからも拡張型の語の方が適しているように思えます。
 
拡張型の方が相応しいのか、オブジェクトがT型に属する条件をみていくことで考えていきたいと思います。
上のコード例で言うと、オブジェクトがT型に属する条件とは、「オブジェクトがstring型のプロパティstrをもつこと」でした。
対して、オブジェクトがS型に属する条件とは、「オブジェクトがstring型のプロパティstrをもつこと」に加えて、「オブジェクトがnumber型のプロパティnumをもつこと」でした。
これらの条件を見比べてみると、S型に属するための条件を満たすオブジェクトは必然的にT型に属する条件を満たしていることがわかります。
 
このことから、型Sが型Tの拡張型なのではなく、部分型であることがわかります。
(拡張型では?と言う疑念は型をオブジェクトのように捉えて包含関係を考えてしまっていたことに起因していたわけですね。)

集合に対応する型

空集合 never

以下の記事でも扱いました、空集合に対応する型としてnever型というものがあります。
https://zenn.dev/axoloto210/articles/advent-calender-2023-day10

全体集合 unknown

unknown型はどんな値でも代入することができる型です。
このことは、任意の型Tの値がunknown型の値でもあることを示しており、T型がunknown型の部分型であることがわかります。このことは、「任意の集合は全体集合の部分集合である」という全体集合の性質に対応していますね。
加えて、T | unknownunknown型となることや、T & unknown型がT型になることがそれぞれ「全体集合との和集合は全体集合であること」、「全体集合との積集合はもとの集合自身であること」という全体集合の性質と対応しています。

type T = {str: string, num: number}
type U = T | unknown //type U = unknown
type V = T & unknown //type V = { str: string; num: number; }

これらのことから、unkonown型は全体集合に対応していると見なすことができます。

関数型の部分型

関数の型にも部分型の関係が発生することがあります。

引数型が同じ場合の部分型

引数の型が同じ場合には、返り値の型によって部分型の関係が確認できます。
Sを型Tの部分型としたとき、引数の型をAとすると、関数型(A) => Sは関数型(A) => Tの部分型となります。
これは、(A) => S型の関数はS型の値を返すので、部分型の定義からT型の値を返す関数と見なすことができるためです。

type A = string | number

type T = {a: A}
type S = {a: A, str: string}

// Fnの型はfunction Fn(arg: A): T
function Fn(arg: A): T{
    return { a: arg }
}
//Gnの型はfunction Gn(arg: A): S
function Gn(arg: A): S {
    return { str: 'str', a: arg }
}

const fnS: (arg: A) => S = Fn //Type '(arg: A) => T' is not assignable to type '(arg: A) => S'. Property 'str' is missing in type 'T' but required in type 'S'.(2322)
const gnT: (arg: A) => T = Gn

上のコード例から、(arg: A) => Tである関数型の変数に(arg: A) => S型である関数Gnを代入できていることがわかり、関数型(A) => Sは関数型(A) => Tの部分型となることがわかります。
引数の型が同じで返り値の型に部分型関係がある場合、関数型についても部分型関係は保存されることがわかります。

返り値の型が同じ場合の部分型

返り値の型が同じ場合にも、引数の型によって部分型の関係が確認できます。
Sを型Tの部分型としたとき、返り値の型をRとすると、関数型(T) => Rは関数型(S) => Rの部分型となります。
これは、(T) => R型の関数の引数T型にはS型の値を代入可能であることからわかります。

type R = string | number

type T = {a: R}
type S = {a: R, str: string}

// Fnの型はfunction Fn(arg: T): R
function Fn(arg: T): R{
    return arg.a 
}
//Gnの型はfunction Gn(arg: S): R
function Gn(arg: S): R{
    return arg.a
}

const fnT: (arg: T) => R = Gn // Type '(arg: S) => R' is not assignable to type '(arg: T) => R'. Types of parameters 'arg' and 'arg' are incompatible. Property 'str' is missing in type 'T' but required in type 'S'.(2322)
const gnS: (arg: S) => R = Fn

上のコード例から、(arg: S) => Rである関数型の変数に(arg: T) => R型である関数Fnを代入できていることがわかり、(arg: T) => Rの値は(arg: S) => Rの値でもあることがわかります。
部分型の定義から、関数型(T) => Rは関数型(S) => Rの部分型となります。
返り値の型が同じで引数の型に部分型関係がある場合、関数型については部分型関係が逆転していることに注意する必要があります。

共変性と反変性

「引数型が同じ場合の部分型」の項でもみたように、返り値の部分型関係が関数型の部分型関係と同じ向きとなる性質を関数型の返り値は関数型の共変の位置にあると言います。
また、「返り値の型が同じ場合の部分型」の項でみた、引数の部分型関係が関数型の部分型関係と反対の向きとなる性質を関数型の引数は関数型の反変の位置にあると言います。

関数型Gが関数型Fの部分型であるとは、「引数が反変の条件を満たす」「返り値が共変の条件を満たす」(と「Fの引数の個数がGの引数の個数以下」)を満たすことであると表すことができます。

脚注
  1. 宣言によって部分型を決定する方法は公称型や名前的部分型と呼ばれています。 ↩︎

GitHubで編集を提案

Discussion