個人的Typescriptの型入門③ 〜 Typescriptの型のディープな世界


※ 当ページには【広告/PR】を含む場合があります。
2024/09/10
個人的Typescriptの型入門② 〜 オブジェクト型の応用
TypesriptでもBitwiseなテンプレートリテラル型で簡単ビット演算チェック
蛸壺の技術ブログ|Typescriptの型のディープな世界

前回はTypescriptのオブジェクト型に応用的な使いこなしを中心にまとめてみました。

合同会社タコスキングダム|蛸壺の技術ブログ
個人的Typescriptの型入門② 〜 オブジェクト型の応用

個人的「型」入門〜オブジェクト型をもっと掘り下げる

今回はTypescriptの応用をフル活用した「型プログラミング」のディープな世界に足を踏み入れる内容になります。

前回の記事同様、以下の詳しい解説されているサイトをベースに、著者が防備録として抑えておきたい利用法をピックアップしています。

参考|TypeScriptの型入門

詳しい説明はそちらのほうをご参照ください。


合同会社タコスキングダム|蛸壺の技術ブログJavascript(js)&Typescript(ts)プログラミング入門〜これから学ぶ人のためのおすすめ書籍&教材の手引き

Typescriptによる「型プログラミング」

実際には、前回の内容のレベルでも十分Typescriptで動くアプリを実装していけるとは思いますが、既にTypeScript界隈では、少ないコードで高度な型を表現する「型芸」(「シェル芸」っぽく)の世界が花開いています。

一見何をやっているか良く分からないものの、そこに秘められた機能美にはただただ驚かされるような「作品」がオンラインでいくつも公開されています。

参考|TypeScript Awesome Template Literal Types

これらの"型芸"を理解するには、いくつか高度な構文に慣れる必要があります。

ということで以降では、コツコツと小出しにテクニックをまとめていきます。

keyof演算子

とあるオブジェクト型・
Tに、keyof Tとすると、「Tのプロパティ名全ての型」になります。

            
            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型は、プロパティfooとbarを持ち、プロパティ名として可能な文字列は'foo'と'bar'のみであり、keyof MyObjはそれらの文字列のみを受け付けるUnion型、すなわち'foo' | 'bar'型になります。

よって、
keyof MyObjの型にない'baz'というプロパティ名を代入しようとすると、エラーとなります。

symbol型をプロパティに含むオブジェクトへのkeyof

JavaScriptではオブジェクトのプロパティ名が、文字列以外の
「シンボル」の場合があります。

ここではシンボルの使い方には興味がないので特には解説はしませんが、場合によっては、シンボルを使ったプロパティを持つオブジェクトにも
keyofしたい場合があるかもしれません。

            
            //新しいシンボルを作成
const symb = Symbol();

const obj = {
    foo: 'str',
    [symb]: 'symb',
};

//'foo' | typeof symb 型
type ObjType = keyof (typeof obj);
        
この例では、objの値からtypeofで型推論されるオブジェクト型にkeyofすることで、オブジェクトのプロパティ名のUnion型を作っています。

結果的に、ここでの
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型は、任意のstring型のプロパティ名をとることができるため、keyof MyObjはstring型になると予想は付きます。

しかし実際には、
string | numberとなっているのは、Javascriptコード変換後は数値のキーもどうせ文字列扱いになるだろうという意図があるようです。

一方で、以下のコードで、インデックスシグネチャのキーがnumber型の場合、
keyof MyObjはnumber型のみになることが分かります。

            
            interface MyObj {
    [foo: number]: string;
}

//MyObjKeyは、number型
type MyObjKey = keyof MyObj;
        

Lookup Types

「Lookup Types」は、2つの型変数
TKに対して、T[K]という構文を用いて、Kがプロパティ名の型であるとき、T[K]がT型のプロパティの型となることを表現します。

以下の例で説明すると、

            
            interface MyObj {
    foo: string;
    bar: number;
}

//strはstring型
const str: MyObj['foo'] = '123';
        
この例での、MyObj['foo']という型が、「Lookup types」です。

T[K]の対応では、TがMyObj型、Kが'foo'型となります。

よって、
MyObj['foo']型は、「MyObj型のfooというプロパティの型」、すなわちstring型となります。

同様の考え方で、
MyObj['bar']なら、number型です。

なお、
MyObj['baz']のようにプロパティ名にはない型を与えるとエラーとなります。

よって、Lookup typesの型変数
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 Typesを組み合わせた関数

Lookup types(
T[K])の型変数Kは、keyof T型の部分型とみなせるが故に、keyof演算子とLookup typesは良くセットとなって利用されます。

以下の例を見てみましょう。

            
            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は2つの型変数TKを持ち、特にKK extends keyof Tと書かれています。

ここでの
K extends keyof Tは、「宣言する型変数Kは、keyof Tの部分型でなければならない」の意味です。

この条件が無いと、返り値の型
T[K]が妥当でない可能性が生じるためエラーとなります。

pick(obj, 'foo')という呼び出しで、Tが
{ foo: string; bar: number; }型、Kが'foo'型となり、その返り値の型は、({ foo: string; bar: number; })['foo']型 = string型となります。

Mapped Types

「mapped types」は、
{[P in K]: T}として、Pという型変数と、KTという何らかの型で表現される構文を持つ型です。ただしKに関しては、string型の部分型である必要があります。

{[P in K]: T}の意味は、「K型の値として可能な各文字列Pに対して、型Tを持つプロパティPが存在するようなオブジェクト型」です。

例えば、
{[P in 'foo' | 'bar']: number}というオブジェクト型は、Kにあたる部分は'foo' | 'bar'なので、Pとしては'foo''bar'という文字列の値が可能であり、これらの名前のプロパティfooとbarがnumber型を持つようなオブジェクト型です。

すなわち、
{[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 Typesの利用法

mapped typesの基本形・
{[P in K]: T}から、さらに話を拡張し、型Tの中でPを使ってみます。

例えば、次のような例です。

            
            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、すなわち元の型かnullかのいずれかであるようなオブジェクト型です。

この例では、
PropNullable<Foo>型は、{foo: string | null; bar: number | null; }型と同じになることが分かります。

Mapped Typesとプロパティ修飾子

mapped typesの
[P in K]部に、プロパティ修飾子(?かreadonly)を付けることもできます。

            
            type Partial<T> = {[P in keyof T]?: T[P]};
        
TypeScriptの標準ライブラリにもあるPartial<T>型は、上のような定義を持ち、Tのプロパティを全てオプショナルにした型となります。

全てのプロパティをreadonlyにする
Readonly<T>も標準ライブラリとしてはありますが、実装するならば以下のようになるでしょう。

            
            type Readonly<T> = {readonly [P in keyof T]: T[P]};
        

Mapped Typesで修飾子を取り除く

逆に、付与されたプロパティ修飾子を取り除くことです。

そのためには、?や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型から、barプロパティが「?」が無くなっていることが分かります。

Mapped Typesの応用

ライブラリの型定義ファイルなどを眺めると、mapped typesを使った関数にしばしば遭遇します。

例えば以下は、オブジェクトリテラルから推論された型のプロパティを全て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 Typesと引数の位置

mapped typesを関数の引数でも書くことはできます。

            
            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]>}の部分型であることを用いて、型Tを{ foo: number; bar: string; baz: never; }型だと推論できています。

これからさらにmapped typesでから、返り値の型を
{ foo: number | undefined; bar: string | undefined; baz: undefined; }型にしています。

なお、bazの型はUnion型の
never | undefinedですが、never型の性質からundefined型に集約されます。

ここで注目すべきは、
pickFirst関数の型引数TをTypescript側にその定義を教えてあげなくても、正しく推論できている点にあります。

Conditional Types

「conditional types」は、4つの型変数を用いて、T extends U ? X : Yという構文で型の条件分岐をすることができます。

通常の条件三項演算子のように、この型は
TUの部分型ならば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における「extends演算子」はinterfaceの継承・拡張だったものが、「conditional types」の登場により、別の重要な役割を与えられました。

conditional typesで用いられる
「T extends U」は、「TがUの部分型ならばtrue、そうでないならfalse」を返す演算子になります。

当然プリミティブ型に限らず、型変数にオブジェクト型を指定しても期待どおりに処理されます。

            
            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 Typesの限界とConditional Types

他の言語でもしばしば問題になりますが、オブジェクトのインスタンスを複製する際に、そのコピーが「深い(deep)」か「浅い(shallow)」か、がテーマに挙がります。

実は「mapped types」でのマッピングは、shallowであり、単体だとdeepな結果は得られません。

例えば上の節で取り上げていた
Readonly<T>などは、プロパティをshallowにreadonlyへ変換するだけです。

            
            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>;
        
例えば、Obj型に対して、Readonly<Obj>{readonly foo: string; readonly bar: { hoge: number; };}型となっています。

この型の中で、barのプロパティhogeがreadonlyになっていないので、deepなマッピングにはなっていないのです。

では、deepなmapped typesにできることを期待して、以下のような再帰的な定義にしてみましょう。

            
            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>の型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 TypesとConditional Typesを組み合わせて型操作する

先程は再帰的なマッピング操作をmapped types単体で行う例を示しましたが、conditional typesを組み合わせることで、より汎用性の高い型表現ができるようになります。

例えば、
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>は、conditional typesで構成され、最初のTが配列の場合、次に配列以外のオブジェクトの、そして最後にそれ以外(すなわちプリミティブ型)の順に評価されます。

配列型に型マッチした場合、
DeepReadonlyArray<T>による処理、それ以外のオブジェクト型はDeepReadonlyObject<T>で処理を分離しています。

プリミティブ型は、そのプロパティの型を考える必要はないため
Tの型をそのまま返します。

細かく見ていくと、配列型をreadonlyにする
DeepReadonlyArray<T>は、要素の型であるTDeepReadonly<T>で再帰的に処理し、配列自体の型は標準ライブラリのReadonlyArray<T>により再表現します。

なお、
ReadonlyArray<T>は、各要素がreadonlyになっている配列を返します。

配列型
Tの要素の型については、例えばT[number]と書くと、「Tに対してnumber型のプロパティ名でアクセスできるプロパティの型」と解釈されます。

次に、
DeepReadonlyObject<T>では、素朴にmapped types単体で各プロパティを処理しています。

ただし、
NonFunctionPropertyNames<T>というconditional typesを利用した型定義を使って、Tのプロパティ名のうちで関数型のものを除外しています。

DeepReadonlyObject<T>で、Tからメソッド(関数型のプロパティ)を除去したい理由は、メソッドから自己を書き換える危険な操作を排除したいパターンを想定しているからです。

Conditional Typesの遅延評価

先程の
DeepReadonly<T>の考え方のキモは、「conditional typesが遅延評価される」ということです。

(※現状、mapped typesも型変数の遅延評価しているようです。)

conditional typesの分岐条件では、型変数がそもそも何がなのかわからないと判定できないので、必然的に遅延評価となります。

            
            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>を適用することが可能になります。

Conditional Typesにおける型抽出(infer)

conditional typesの強力な機能の一つである、
「infer」修飾子を使うことでマッチした型を抽出することができます。

例えば、以下のような例です。

            
            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のroleプロパティが存在するかを評価し、存在する場合はその型を、存在しない場合はnull型を返えす、という処理になっています。

別の例で、標準ライブラリにもある
ReturnType<T>をより簡素化してみたMyReturnType<T>で、inferを使ってみます。

            
            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で評価され、そうでない場合にはany型が返ります。

なお標準ライブラリの
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 Typesと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!"型という文字列リテラル型から、"Hello, "以外の部分を抜き出して、新しい文字列リテラル型にできるか否かまで判定してくれます。


合同会社タコスキングダム|蛸壺の技術ブログJavascript(js)&Typescript(ts)プログラミング入門〜これから学ぶ人のためのおすすめ書籍&教材の手引き

まとめ

当初自分のTypescriptテクニックの防備録集とするつもりが、結構な長丁場な内容になってしましました。

今後もさらなる進化を遂げるであろうTypescriptの型については、継続的に勉強していく必要がありますが、記事としては一旦ここで打ち止めです。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログJavascript(js)&Typescript(ts)プログラミング入門〜これから学ぶ人のためのおすすめ書籍&教材の手引き