カテゴリー
個人的Typescriptの型入門③ 〜 Typescriptの型のディープな世界
※ 当ページには【広告/PR】を含む場合があります。
2024/09/10
2025/07/15

前回はTypescriptのオブジェクト型に応用的な使いこなしを中心にまとめてみました。
今回はTypescriptの応用をフル活用した「型プログラミング」のディープな世界に足を踏み入れる内容になります。
前回の記事同様、以下の詳しい解説されているサイトをベースに、著者が防備録として抑えておきたい利用法をピックアップしています。
詳しい説明はそちらのほうをご参照ください。
型の学習には「Typescript Playground」を使おう
復習も兼ねて、型の基礎のほうから徐々にTypescriptの型の使い方を慣らして行きましょう。
なおTypescriptの型チェックは、VSCode等のエディタで用意されているものを扱うことを想定していますが、サッとTypescriptを試したい際には定番の
Typescriptによる「型プログラミング」
実際には、前回の内容のレベルでも十分Typescriptで動くアプリを実装していけるとは思いますが、既にTypeScript界隈では、少ないコードで高度な型を表現する「型芸」(「シェル芸」っぽく)の世界が花開いています。
一見何をやっているか良く分からないものの、そこに秘められた機能美にはただただ驚かされるような「作品」がオンラインでいくつも公開されています。
これらの"型芸"を理解するには、いくつか高度な構文に慣れる必要があります。
ということで以降では、コツコツと小出しにテクニックをまとめていきます。
keyof演算子
keyof演算子とは
とあるオブジェクト型・
T
keyof T
ここではこのような型を
「keyof型」
具体例で見てみましょう。
interface MyObj {
foo: string;
bar: number;
}
//keyは'foo' | 'bar'型
let key: keyof MyObj;
key = 'foo';
key = 'bar';
//✘ Type '"baz"' is not assignable to type '"foo" | "bar"'.
key = 'baz';
この例では、
MyObj
keyof MyObj
'foo' | 'bar'
よって、
keyof MyObj
symbol型をプロパティに含むオブジェクトへのkeyof
JavaScriptではオブジェクトのプロパティ名が、文字列以外の
ここではシンボルの使い方には興味がないので特には解説はしませんが、場合によっては、シンボルを使ったプロパティを持つオブジェクトにも
keyof
//新しいシンボルを作成
const symb = Symbol();
const obj = {
foo: 'str',
[symb]: 'symb',
};
//'foo' | typeof symb 型
type ObjType = keyof (typeof obj);
この例では、objの値から
typeof
keyof
結果的に、ここでの
ObjType
'foo' | typeof symb
注目すべきは、オブジェクトのプロパティにシンボルの型を含む場合、そのオブジェクト型を
keyof
typeof <シンボル名>
TypeScriptではシンボルはsymbol型ですが、プロパティ名としてはシンボルはひとつずつ実体が異なるため
symb
'foo' | symbol
keyofの注意点
数値リテラルを使ってプロパティを宣言した場合、keyof型にはnumberの部分型が含まれる場合もあります。
const obj = {
foo: 'str',
0: 'num',
};
//0 | 'foo'型
type ObjType = keyof (typeof obj);
なお、TypeScriptでは数値型をプロパティのキーに指定できますが、JavaScriptにはプロパティキーに数値は使えないルールのため、文字列へ強制的に変換されます。
インデックスシグネチャとkeyof演算子
前回説明した「インデックスシグネチャ」を持つオブジェクト型に、
keyof
interface MyObj {
[foo: string]: number;
}
//MyObjKeyは、string | number型
type MyObjKey = keyof MyObj;
この例で、
MyObj
keyof MyObj
しかし実際には、
string | number
一方で、以下のコードで、インデックスシグネチャのキーがnumber型の場合、
keyof MyObj
interface MyObj {
[foo: number]: string;
}
//MyObjKeyは、number型
type MyObjKey = keyof MyObj;
Lookup型
Lookup型とは
「Lookup型」は、2つの型変数
T
K
T[K]
K
string | number | symbol
PropertyKey
このような条件下で、
T[K]
以下の例で説明すると、
interface MyObj {
foo: string;
bar: number;
}
//strはstring型
const str: MyObj['foo'] = '123';
この例での、
MyObj['foo']
T[K]
MyObj
'foo'
よって、
MyObj['foo']
string
同様の考え方で、
MyObj['bar']
number
なお、
MyObj['baz']
つまり、Lookup型の型変数
K
keyof T
interface MyObj {
foo: string;
bar: number;
}
//strはstring | numbder型
const str: MyObj[keyof MyObj] = 123;
実用性は少ないですが
MyObj[keyof MyObj]
MyObj['foo' | 'bar']
string | number
keyof型とLookup型を組み合わせた利用法
先程の説明の通り、Lookup型(
T[K]
K
keyof T
以下の例を見てみましょう。
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const obj = {
foo: 'string',
bar: 123,
};
const str: string = pick(obj, 'foo');
const num: number = pick(obj, 'bar');
//✘ Argument of type '"baz"' is not assignable to parameter of type '"foo" | "bar"'.
pick(obj, 'baz');
この関数
pick
pick(obj, 'foo')
obj.foo
注目すべきは、この関数が返す値に、正しい型を付けることができているという点です。
pick
T
K
K
K extends keyof T
ここでの
K extends keyof T
K
keyof T
この条件が無いと、返り値の型
T[K]
pick(obj, 'foo')という呼び出しで、Tが
{ foo: string; bar: number; }
'foo'
({ foo: string; bar: number; })['foo']
Mapped型
Mapped型とは
「Mapped型」は、
{[P in K]: T}
P
K
T
ただし
K
{[P in K]: T}
「K型の値として可能な各文字列Pに対して、型Tを持つプロパティPが存在するようなオブジェクト型」
抽象的な定義では分かりにくいですが例えば、
{[P in 'foo' | 'bar']: number}
'foo' | 'bar'
'foo'
'bar'
すなわち、
{[P in 'foo' | 'bar']: number}
{ foo: number; bar: number; }
type Obj1 = {[P in 'foo' | 'bar']: number};
interface Obj2 {
foo: number;
bar: number;
}
const obj1: Obj1 = {foo: 3, bar: 5};
const obj2: Obj2 = obj1;
const obj3: Obj1 = obj2;
Mapped型の特殊型〜「ホモモーフィックMapped型」
Mapped型の基本形・
{[P in K]: T}
{[P in keyof T]: U}
"ホモモーフィック"は
準同型
数学的な表現に言い換えると、写像が準同型であれば、「代数的構造を保存する」、「代数的構造と両立する」、「代数的構造と可換である」などと呼ぶようです。
そんな"ホモモーフィック"という言葉のもつ、「構造を保存する」という意味合いにあやかって付けられたのが、「ホモモーフィックMapped型」です。
改めて
{[P in keyof T]: U}
T
T
このことを踏まえて、単なるMapped型とホモモーフィックMapped型の違いを以下の例で見てみましょう。
type Obj = {
readonly num: number
obj?: {
str: string,
readonly num: number
}
}
type keyForObj = "num" | "obj";
/**
* 単なるMapped型(ホモモーフィックなマッピングではない)では構造は保存されない
* type NonHomomorphic = {
* num: number;
* obj: {
* str: string;
* readonly num: number;
* } | undefined;
* }
*/
type NonHomomorphic = {
[P in keyForObj]: Obj[P]
}
/**
* ホモモーフィックなマッピングで構造は保存される
* type Homomorphic = {
* readonly num: number;
* obj?: {
* str: string;
* readonly num: number;
* } | undefined;
* }
*/
type Homomorphic = {
[P in keyof Obj]: Obj[P]
}
このホモモーフィックMapped型の利用の幅広く、Mapped型のテクニックのほとんどこのホモモーフィックMapped型になっているのではないでしょうか。
例えば次のような例で考えてみましょう。
type PropNullable<T> = {[P in keyof T]: T[P] | null};
interface Foo {
foo: string;
bar: number;
}
const obj: PropNullable<Foo> = {
foo: 'foobar',
bar: null,
};
ここでは型変数
T
PropNullable<T>
この型は、T型のオブジェクトの各プロパティPの型が、
T[P] | null
この例では、
PropNullable<Foo>
{foo: string | null; bar: number | null; }
Mapped型とプロパティ修飾子
Mapped型の
[P in K]
type Partial<T> = {[P in keyof T]?: T[P]};
TypeScriptの標準ライブラリにもある
Partial<T>
全てのプロパティをreadonlyにする
Readonly<T>
type Readonly<T> = {readonly [P in keyof T]: T[P]};
Mapped型で修飾子を取り除く
逆に、付与されたプロパティ修飾子を取り除くことです。
そのためには、?やreadonlyの前に
「-」
type MyRequired<T> = {[P in keyof T]-?: T[P]};
この例では、T型のすべてのプロパティから
「?」を取り除いた
Required<T>
type MyRequired<T> = {[P in keyof T]-?: T[P]};
interface Foo {
foo: string;
bar?: number;
}
///ReqFooは{ foo: string; bar: number; }型
type ReqFoo = MyRequired<Foo>;
使用例してみると、
ReqFoo
Mapped型のさらなる応用
ライブラリの型定義ファイルなどを眺めると、Mapped型を使った関数にしばしば遭遇します。
例えば以下は、オブジェクトリテラルから推論された型のプロパティを全てstring型した型を返す
function propStringify<T>(obj: T): {[P in keyof T]: string} {
const result = {} as {[P in keyof T]: string};
// const result = {} as any;でも可
for (const key in obj) {
result[key] = String(obj[key]);
}
return result;
}
const foo = {foo: 'foo', bar: 123};
//aは{foo: string; bar: string; }型
const a = propStringify(foo);
この例で、asを使ってresultの型を
{[P in keyof T]: string}
Mapped型と引数の位置
Mapped型を関数の引数でも書くことはできます。
function pickFirst<T>(obj: {[P in keyof T]: Array<T[P]>}): {[P in keyof T]: T[P] | undefined} {
const result: any = {};
for (const key in obj) {
result[key] = obj[key][0];
}
return result;
}
const obj = {
foo: [0, 1, 2],
bar: ['foo', 'bar'],
baz: [],
};
const picked = pickFirst(obj);
picked.foo; // number | undefined型
picked.bar; // string | undefined型
picked.baz; // undefined型
この例ではまず、変数objは
{ foo: number[]; bar: string[]; baz: never[]; }
{[P in keyof T]: Array<T[P]>}
{ foo: number; bar: string; baz: never; }
これからさらにMapped型でから、返り値の型を
{ foo: number | undefined; bar: string | undefined; baz: undefined; }
なお、bazの型はUnion型の
never | undefined
undefined
ここで注目すべきは、
pickFirst
Conditional型
Conditional型とは
T extends U ? X : Y
この例でいうと、通常の条件三項演算子のように、この型は
T
U
X
Y
例えば以下のような実装が考えられます。
type A<T> = T extends string ? string : number;
//Bはstring型
type B = A<string>;
//Cはnumber型
type C = A<boolean>;
//Dはstring型
type D = A<'hello'>;
//Eはnumber型
type E = A<123>;
復習になりますが、もともとTypescriptにおける
Conditional型で用いられる
「T extends U」
当然プリミティブ型に限らず、型変数にオブジェクト型を指定しても期待どおりに処理されます。
type UserA = {
name: string;
age: number;
};
type UserB = {
name: string;
email: string;
};
type UserC = {
name: string;
age: number;
email: string;
};
type U<T> = T extends UserA ? number : string;
//Aはnumber型
type A = U<UserA>;
//Bはstring型
type B = U<UserB>;
//Cはnumber型
type C = U<UserC>;
Mapped型の限界とConditional型
他の言語でもしばしば問題になりますが、オブジェクトのインスタンスを複製する際に、そのコピーが「深い(deep)」か「浅い(shallow)」か、がテーマに挙がります。
実は「Mapped型」でのマッピングは、shallowであり、単体だとdeepな結果は得られません。
例えば上の節で取り上げていた
Readonly<T>
type MyReadonly<T> = {readonly [P in keyof T]: T[P]};
interface Obj{
foo: string;
bar: {
hoge: number;
};
}
//Aは以下の型:
//{
// readonly foo: string;
// readonly bar: {
// hoge: number;
// };
//}
type A = MyReadonly<Obj>;
const a: A = {
foo: 'aaa',
bar: {
hoge: 123
}
}
//Cannot assign to 'foo' because it is a read-only property.
a.foo = 'bbb';
//readonlyではないので書き換えOK
a.bar.hoge = 456;
例えばこの場合、
Obj
MyReadonly<Obj>
{readonly foo: string; readonly bar: { hoge: number; };}
注意すべきはこの型の中で、barのプロパティhogeがreadonlyになっていないので、deepなマッピングにはなっていないのです。
ではどうすればdeepなMapped型にできるのかというと、以下のような再帰的な定義にしてみます。
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
}
これを実際に試してみますと、
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
}
interface Obj{
foo: string;
bar: {
hoge: number;
};
}
//Aは以下の型:
//{
// readonly foo: string;
// readonly bar: DeepReadonly<{
// hoge: number;
// }>;
//}
type A = DeepReadonly<Obj>;
const obj: A = {
foo: 'foo',
bar: {
hoge: 3,
},
};
//✘ Cannot assign to 'hoge' because it is a constant or a read-only property.
obj.bar.hoge = 3;
となり、deepなreadonlyなプロパティになっています。
最近のTypescriptではより推論能力が強化され、
DeepReadonly<T>
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
}
function readonlyify<T>(obj: T): DeepReadonly<T> {
return obj as DeepReadonly<T>;
}
const obj = {
foo: 'foo',
bar: {
hoge: 3,
piyo: {
moge: 8
}
},
};
const a = readonlyify(obj);
//✘ Cannot assign to 'hoge' because it is a read-only property.
a.bar.hoge = 3;
//✘ Cannot assign to 'moge' because it is a read-only property.
a.bar.piyo.moge = 8;
Mapped型とConditional型を組み合わせた型操作
先程は再帰的なマッピング操作をMapped型単体で行う例を示しましたが、Conditional型を組み合わせることで、より汎用性の高い型表現ができるようになります。
例えば、
DeepReadonly<T>
type DeepReadonly<T> =
T extends any[] ? DeepReadonlyArray<T[number]> :
T extends object ? DeepReadonlyObject<T> :
T;
interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {};
type DeepReadonlyObject<T> = {
readonly [P in NonFunctionPropertyNames<T>]: DeepReadonly<T[P]>;
};
type NonFunctionPropertyNames<T> = {
[K in keyof T]: T[K] extends Function ? never : K
} [keyof T];
こちらはいくつかの型定義で構成される形になっています。
まず、本体の
DeepReadonly<T>
T
配列型に型マッチした場合、
DeepReadonlyArray<T>
DeepReadonlyObject<T>
プリミティブ型は、そのプロパティの型を考える必要はないため
T
細かく見ていくと、配列型をreadonlyにする
DeepReadonlyArray<T>
T
DeepReadonly<T>
ReadonlyArray<T>
なお、
ReadonlyArray<T>
配列型
T
T[number]
次に、
DeepReadonlyObject<T>
ただし、
NonFunctionPropertyNames<T>
T
DeepReadonlyObject<T>
T
Conditional型は遅延評価される
先程の
DeepReadonly<T>
(※現在ではMapped型も型変数の遅延評価しているようです。)
Conditional型の分岐条件では、型変数がそもそも何がなのかわからないと判定できないので、必然的に遅延評価となります。
type List<T> = {
value: T;
next: List<T>;
} | undefined;
type A = DeepReadonly<List<number>>;
const a: A = {
value: 1,
next: {
value: 2,
next: undefined
}
};
//Cannot assign to 'value' because it is a read-only property.
a.value = 2;
//Cannot assign to 'next' because it is a read-only property.
a.next!.next = {value: 3};
遅延評価により、型評価時に無限ループすることを防いでくれるため、
List<T>
DeepReadonly<T>
infer修飾子
Conditional型における型抽出〜「infer」
Conditional型の強力な機能の一つである、
例えば、以下のような例です。
type UserA = {
name: string;
role: 'admin' | 'user'; //この型を抽出したい
};
type UserB = {
name: string;
age:number
};
type A<T> = T extends { role: infer U } ? U : null;
type B = A<UserA>; //"admin" | "user"
type C = A<UserB>; // null
この場合、
T extends { key: infer U } ? U : V
型変数
T
別の例で、標準ライブラリにもある
ReturnType<T>
MyReturnType<T>
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : T;
//Bはnumber型
type B = MyReturnType<number>;
//Cはvoid型
type C = MyReturnType<(arg: number[])=>void>;
この例では、型変数
T
(...args: any[])=>R
R
なお標準ライブラリの
ReturnType<T>
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any
inferの重ね掛け
同じ型変数に対して、inferを複数箇所に使うことも可能です。
inferを複数使う場合、推論される型がUnion型やIntersection型になるなどより複雑化します。
これは次の例で確かめられます。
type Foo<T> = T extends {
foo: infer U;
bar: infer U;
hoge: (arg: infer V)=> void;
piyo: (arg: infer V)=> void;
} ? [U, V] : never;
interface Obj {
foo: string;
bar: number;
hoge: (arg: string)=>void;
piyo: (arg: number)=>void;
}
//tは[string | number, never]型
declare let t: Foo<Obj>;
型システムを考えれば、Uは共変な位置に、Vは反変な位置に出現しているため、UがUnion型で表現されて、VがIntersection型で表現されます。
Conditional型とinferによる文字列マッチング
最後に、inferとテンプレートリテラル型を組み合わせることで、型レベルの文字列操作が可能になります。
以下の例で試してみましょう。
type PartialHello<S extends string> = S extends `Hello, ${infer P}!` ? P : unknown;
//"world"型
type T1 = PartialHello<"Hello, world!">;
//unknown型
type T2 = PartialHello<"Hell, world!">;
例えば、
"Hello, world!"
まとめ
当初自分のTypescriptテクニックの防備録集とするつもりが、結構な長丁場な内容になってしましました。
今後もさらなる進化を遂げるであろうTypescriptの型については、継続的に勉強していく必要がありますが、記事としては一旦ここで打ち止めです。
記事を書いた人
ナンデモ系エンジニア
主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。
カテゴリー