個人的Typescriptの型入門② 〜 オブジェクト型の応用


※ 当ページには【広告/PR】を含む場合があります。
2024/09/09
個人的Typescriptの型入門① 〜 型の基本
個人的Typescriptの型入門③ 〜 Typescriptの型のディープな世界
蛸壺の技術ブログ|個人的Typescriptの型入門② 〜 オブジェクト型の応用



前回はTypescriptの型の初歩を中心にまとめてみました。

合同会社タコスキングダム|蛸壺の技術ブログ
個人的Typescriptの型入門① 〜 型の基本

個人的Typescriptの「型」入門



今回は基礎のレベルから少し進んで、「オブジェクト型」について掘り下げていきます。
前回の記事同様、以下の詳しい解説されているサイトをベースに、著者が防備録として抑えておきたい利用法をピックアップしています。

参考|TypeScriptの型入門

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



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

型の学習には「Typescript Playground」を使おう



復習も兼ねて、型の基礎のほうから徐々にTypescriptの型の使い方を慣らして行きましょう。
なおTypescriptの型チェックは、VSCode等のエディタで用意されているものを扱うことを想定していますが、サッとTypescriptを試したい際には定番の
「Typescript Playground」 を使うのがもっとも手っ取り早いでしょう。

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

オブジェクト型の応用



ここからよりオブジェクト型に関連した応用的なテーマに進みます。

「?」プロパティ修飾子



オブジェクト型のプロパティに対しては、
「?」 と後述する 「readonly」 という2種類のプロパティ修飾子を付けることができます。
まず
「?」 を付けたプロパティは、「オプショナル(省略可能)なプロパティ」になります。

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

let obj: MyObj = { foo: 'string' };

obj = { foo: 'foo', bar: 100 };

        

この例ではbarが省略可能なプロパティになっています。

オプショナルなプロパティへのアクセス



JavaScriptでは存在しないプロパティにアクセスすると
undefined が返ります。
このことを反映して、先程のMyObjのbarプロパティにアクセスした場合に得られる型は、
number | undefined となります。
つまり、
? 修飾子を付けられたプロパティは、自動的にundefined型とのUnion型になります。
よってオプショナルなプロパティをもつUnion型では、undefinedチェックを行う必要があります。

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

function func(obj: MyObj): number {
    return obj.bar !== undefined ? obj.bar * 100 : 0;
}

        

極力「?」修飾子に頼らない



Typescriptコードの実装指針として、なるべく
? を使わず、 number | undefined 型のように、undefined型とのUnion型で明示に書いたほうが後々コードの保守性がよくなります。

? 修飾子は確かに便利ですが、シンプルすぎるが故に、書き忘れ/書き間違いのミスが度々起こります。

            interface MyObj1 {
    foo: string;
    bar?: number;
}

interface MyObj2 {
    foo: string;
    bar: number | undefined;
}

//オプショナルなプロパティはなくてもエラーは出ない
let obj: MyObj1 = {
    foo: 'string',
};

//✘ Type '{ foo: string; }' is not assignable to type 'MyObj'.
//   Property 'bar' is missing in type '{ foo: string; }'.
let obj: MyObj2 = {
    foo: 'string',
};

        

「exactOptionalPropertyTypes」コンパイラオプション



オプショナルなプロパティの挙動は、
exactOptionalPropertyTypes コンパイラオプションで変わります。
このオプションはデフォルトで無効です。
このオプションが無効の場合、オプショナルなプロパティには明示的に
undefined を入れることができます。

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

//exactOptionalPropertyTypesが無効の場合、以下は全部OK
const obj1: MyObj = { foo: "pichu" };
const obj2: MyObj = { foo: "pikachu", bar: 25 };
const obj3: MyObj = { foo: "raichu", bar: undefined };

        

逆に有効の場合、オプショナルなプロパティに
undefined を入れることができなくなります。

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

//exactOptionalPropertyTypesが有効の場合:
//✘ Type 'undefined' is not assignable to type 'number'.
const obj3: MyObj = { foo: "raichu", bar: undefined };

        

このオプションが有効な状態であれば、
bar?: number; と書いておきながら、barにundefinedを入れて初期化するというあまり直感的でなかった挙動を、事前に防ぐことができます。
つまり、
bar?: number; と宣言されたプロパティに対して、「number型の値が入っている」か「プロパティが存在しない」のどちらかが保証されます。
こうすることで、in演算子を用いて型の絞り込みが安心して行えるメリットがあります。

            //exactOptionalPropertyTypesが有効の状態
interface MyObj {
    foo: string;
    bar?: number;
}

function func(obj: MyObj) {
    if ("bar" in obj) {
        //obj.barはnumber型
        console.log(obj.bar.toFixed(1));
    }
}

        

注意が必要なのは、自身のプロジェクトで
exactOptionalPropertyTypes が有効になっていても、外部から提供されたライブラリでは無効の状態で作られているかもしれない点です。
こちらの設定では有効だとしても、別のライブラリから提供された型定義にオプショナルなプロパティには「undefined型の値が入っている」という状態になる可能性もあります。


「readonly」プロパティ修飾子



もうひとつの
「readonly」 修飾子は、これを付けて宣言されたプロパティは再代入できなくすることが可能です。
その意味で、Typescript独自の「constのプロパティ版」と言えます。

            interface MyObj {
    readonly foo: string;
}

const obj: MyObj = {
    foo: 'Hey!',
};

//✘ Cannot assign to 'foo' because it is a constant or a read-only property.
obj.foo = 'Hi';

        

readonlyの注意点



readonly演算子でプロパティ書き換え不可が保証されるかといえばそうでもなく、次の例に示すように、readonlyでない型の値からの参照を経由して書き換えできます。

            interface MyObj {
    readonly foo: string;
}
interface MyObj2 {
    foo: string;
}

const obj: MyObj = { foo: 'Hey!', }

const obj2: MyObj2 = obj;
obj2.foo = 'Hi';

console.log(obj.foo); // 'Hi'

        

インデックスシグネチャ



オブジェクト型のプロパティの特殊な記法に、
「インデックスシグネチャ」 があります。
インデックスシグネチャは、以下のように使います。

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

const obj: MyObj = {};

const num: number = obj.foo;
const num2: number = obj.bar;

        

ここでの、オブジェクト型の宣言の中で、
[key: string]: number; という部分がインデックスシグネチャです。
このように書くと、名前がstring型である任意のプロパティに対して、全てnumber型を持つ、という意味になります。
例では、obj.fooやobj.barなどは全てnumber型です。

インデックスシグネチャの注意点



インデックスシグネチャを使うと、そもそも型安全な設計ができません。

前回説明した「{ }」型 のobjでも、実際にはobj.fooなどundefinedなものが、undefined型以外の型の値ある、と見なされてしまいます。
インデックスシグネチャにはリスクがあるものの、配列型の定義には欠かせない構文なのもまた事実です。
実際、配列型の定義は概ね下のような感じです。

            interface Array<T> {
    [idx: number]: T;
    length: number;
    // メソッドの定義が続く
    // ...
}

        

ちなみに、この例のようにインデックスシグネチャ以外のプロパティ宣言があった場合、そちらの定義が優先されます。
オブジェクト型を辞書として独自に実装する場合は、あえてインデックスシグネチャのテクニックは避けて、組込みユーティリティの
Mapクラス を使いましょう。

関数シグネチャ

「関数シグネチャ」 を使うことで、関数型をオブジェクト型の記法で表現する方法があります。
以下の例を見てください。

            interface Func {
    (arg: number): void;
}

const f: Func = (arg: number) => { console.log(arg); };

        

ここでの関数シグネチャは
(arg: number): void; の部分で、このオブジェクト型は、number型の引数をひとつ取る関数型であることを表しています。
この記法は通常のプロパティの宣言と併用ができるので、関数型だけど同時に特定のプロパティを持っているようなオブジェクト型、つまり、関数型とオブジェクト型のIntersection型をまとめて表すことができます。


複数の関数シグネチャによるオーバーローディング



複数の関数シグネチャを書くと、
関数のオーバーローディング を表現できます。

            interface Func {
    foo: string;
    (arg: number): void;
    (arg: string): string;
}

        

この型が表す値は、string型のfooプロパティを持つオブジェクト型、かつ、関数としてnumber型を引数で呼び出す場合は何も返さない関数型、かつ、string型を引数で呼び出す場合はstring型の値を返すような関数型、となります。
関数のシグネチャの違いだけで、関数のオーバロードを簡潔に表現できるのは便利です。

newシグネチャ

「newシグネチャ」 は、コンストラクタであることを表す特殊な関数シグネチャです。

            interface Ctor<T> {
    new(): T;
}

class Foo {
    public bar: number | undefined;
}

const f: Ctor<Foo> = Foo;

const obj = new f();

        

ここでの
Ctor<T> 型は、引数なしで new すると、T型の値が返るような関数型(=コンストラクタ関数)を表しています。
クラス定義したFooはコンストラクタ関数(new)を持っているので、
Ctor<Foo> に代入可能です。
ちなみに、関数型を
(foo: string)=>number のように書けたのと同様に、newシグネチャしかないコンストラクタだけの関数型を、 new()=>Foo のように表現することもできます。

asによるダウンキャスト

「as演算子」 はTypeScript独自の構文で、 評価式 as 型 とすることで、ダウンキャストすることができます。
ダウンキャストの操作は、一般に型安全ではありませんが、Union型などをその部分型に当てはめるときに必要です。

            function rand(): string | number {
    if (Math.random() < 0.5) {
        return 'hello';
    } else {
        return 123;
    }
}

const value = rand();

const str = value as number;
console.log(str * 10);

        

この例では、valueは
string | number 型の値ですが、 value as number によりnumber型に矯正しており、そのあとの変数strはnumber型となります。
当然、valueはstring型かもしれないので、asでダウンキャストするのは安全な操作ではありません。

asの注意点



asを使っても型キャストに全く関係ない2つの型の間で変換することはできません。

            const value = 'foo';

//✘ Type 'string' cannot be converted to type 'number'.
const str = value as number;

        

ただ、any型かunknown型を経由すれば半ば強制的に型変換できます。
その場合、asはダウンキャストではなくアップキャストになります。

                const value = 'foo';
    const str = value as unknown as number;

        

この例では、最初のas unknownで行われているのはアップキャストで、その後as numberでダウンキャストしています。
他のアップキャストの例として、文字列リテラル型・'foo'型をstring型へアップキャストしている例です。

            //const foo: string = 'foo'; と同じ結果になる
const foo = 'foo' as string;

        

例のように、アップキャスト自体はasを使わなくてもできる安全な操作ですが、アップキャストにまでasを使うのは、危険なダウンキャストと見分けがつかないので、利用は控えたいところです。

readonlyな配列型



配列型やタプル型においても
readonly の概念が存在します。
ただし、配列型やタプル型全体をreadonlyに設定できるだけで、オブジェクト型の場合でプロパティごとにreadonlyを制御できるのに対して、配列型やタプル型は要素ごとに細かい制御はできません。

            const arr: readonly number[] = [1, 2, 3];

//✘ Index signature in type 'readonly number[]' only permits reading.
arr[0] = 100;

//✘ Property 'push' does not exist on type 'readonly number[]'
arr.push(4);

        

例のように、readonlyな配列型は
readonly T[] のように書きます。
reaodnlyなプロパティと同じく、readonlyな配列の要素を書き換えようとするとエラーとなる他、pushなどの配列を破壊的メソッドは除去されており使えません。
なお、配列型を
T[]Array<T> と書くことができましたが、readonlyな配列型は readonly T[]ReadonlyArray<T> ( readonly Array<T> ではない)を使います。

readonlyなタプル型



readonlyなタプル型は、タプル型の前にreadonlyを付けて表現します。

            const tuple: readonly [string, number] = ['foo', 123];

//✘ Cannot assign to '0' because it is a read-only property.
tuple[0] = 'bar';

        

この例では、tupleは
readonly [string, number] 型となり、タプルの各要素を書き換えることはできません。

Variadic Tuple Types

前回説明した「可変長タプル型」 で挿入することで、可変長を表現したものでした。
さらに進んだ機能として後付で実装されたものが、
「Variadic Tuple Types」 です(英訳するとこちらが「可変長タプル型」ですが...)。
この機能は、タプル型に、別のタプル型を付け加えた別のタプル型を作ることができます。

            type SNS = [string, number, string];

//[string, string, number, string, number]型
type SSNSN = [string, ...SNS, number];

        

Variadic Tuple Typesの応用

「Variadic Tuple Types」 の画期的な機能に、 ...T (...型変数)の形で使うと型推論ができる点があります。

            function removeFirst<T, Rest extends readonly unknown[]>(
    arr: [T, ...Rest]
): Rest {
    const [, ...rest] = arr;
    return rest;
}

//arrは[number, number, string]型
const arr = removeFirst([1, 2, 3, "foo"]);

        

この例では、関数removeFirstの型引数TおよびRestが、それぞれnumberおよび[number, number, string]であると判別され、変数arrが
[number, number, string] 型であることが型推論されます。
特に、引数
[1, 2, 3, "foo"] を、タプル型 [T, ...Rest] に当てはめる高度な推論を、TypeScriptが自動で行なってくれます。
上の例で言えば、removeFirst内の変数restの型が自動的にRestと推論されている点も注目に値し
なお、型引数を
...T の形で使うには、 T が配列型またはタプル型であるという制約を明示に宣言する必要があります。
上の例でいうと、
Rest extends readonly unknown[] の部分がこれに当たります。

「[...T]」と「T」の違い



型変数で、
[...T] と書くと、Tの中身を展開して、再び固めている操作で、一見すると元のTに戻りそうな気がします。
実際には、
[...T]T はちょっとした違いを生みます。

            function func1<T extends readonly unknown[]>(arr: T): T {
    return arr;
}

function func2<T extends readonly unknown[]>(arr: [...T]): T {
    return arr;
}

//arr1はnumber[]型
const arr1 = func1([1, 2, 3]);
//arr2は[number, number, number]型
const arr2 = func2([1, 2, 3]);

        

この例のように、型引数の推論時に、
T の引数に当てはめられた場合は配列型、 [...T] の場合はタプル型がそれぞれ推論されています。
関数に渡された配列の各要素の型を得たい場合、配列型にしたくない場面で活用できるかもしれません。

テンプレートリテラル型

「テンプレートリテラル型」 は、Javascriptでも良く使うテンプレートリテラル構文を使って、特定の文字列パターンを受け入れる型です。
一般的なJavascriptのテンプレートリテラルについては以下のサイトをご覧ください。

テンプレートリテラル (テンプレート文字列) - MDN Doc前回説明した文字列リテラル型 は、一種類の文字列のみを受け入れる型でしたが、テンプレートリテラル型はもっと柔軟な表現をもった型になります。

            type HelloStr = `Hello, ${string}`;

const str1: HelloStr = "Hello, world!";
const str2: HelloStr = "Hello, uhyo";
//✘ Type '"Hell, world!"' is not assignable to type '`Hello, ${string}`'.
const str3: HelloStr = "Hell, world!";

        

この例では、
HelloStr 型は、 Hello, の後に任意のstring型の値がくる文字列をまとめて文字列リテラル型にしています。
これで
Hello, で始まる文字列だけを受け入れる型となります。

テンプレートリテラルの利用法



テンプレートリテラル型では、テンプレートリテラルからのほとんどの文字列をリテラル型として宣言できるのですが、数値型(number型)を文字列へ変換する際に、文字列フォーマットによっては使えないものもあります。

            type PriceStr = `${number}円`;

const str1: PriceStr = "100円";
const str2: PriceStr = "-50円";
const str3: PriceStr = "3.14円"
const str4: PriceStr = "1e100円";

// ここから下は全部エラー
const str5: PriceStr = "1_000_000円";
const str6: PriceStr = "円";
const str7: PriceStr = "1,234円";
const str8: PriceStr = "NaN円";
const str9: PriceStr = "Infinity円";

        

例のように、使えるフォーマットとそうでないものが微妙に区別される
${number型} を含むテンプレートリテラルは扱いにくく、実用的といえません。
テンプレートリテラル型の実用性を考えると、
${string型} や、 ${文字列リテラル型のUnion型} を利用しましょう。

as const

「as const」 によって、型推論の方法を指示することができます。
つまり、as constは、各種リテラル(文字列/数値/ブール値/オブジェクト/配列)に作用し、その値が書き換えを意図していないことを表します。

前回 の復習になりますが、リテラル型の値をvarやletで宣言した変数に入れると、その値は書き換え可能なために、リテラル型ではなく、プリミティブ型が推論されるルールを説明しました。

            //fooはstring型
let foo = '123';

        

この
'123'as const を付けると、書き換え不可の値として扱われるため、変数foo2はstring型ではなく、文字列リテラル型の "123" 型となります。

            //foo2は"123"型
let foo2 = '123' as const;

        

as constの使いどころ

「as const」 の真骨頂は、オブジェクトリテラルで使う場合です。
以下の例で説明します。

            const obj = {
    foo: "123",
    bar: [1, 2, 3]
};
//👇objの型は
// {foo: string; bar: number[]}

const obj2 = {
    foo: "123",
    bar: [1, 2, 3]
} as const;
//👇obj2は型
// {
//     readonly foo: "123";
//     readonly bar: readonly [1, 2, 3];
// }

        

まず
as const 無しの場合、オブジェクトリテラルのプロパティはすべてプリミティブ型に推論され、結果としてobjは { foo: string; bar: number[] } 型と推論されてしまいます。
オブジェクトリテラルのreadonlyでないプロパティは、結局
obj.foo = "456"; のようにすれば書き換えることができるので、リテラル型としては見なされないためです。
対して、オブジェクトリテラルに
as const を付けたobj2の場合、オブジェクトリテラルのプロパティに対して、再帰的に可能な限りリテラル型で推論されているのが分かります。
なお、obj2の変数barについて、配列リテラルへas constが適用され、
readonlyタプル型 ( readonly [1, 2, 3] 型)が推論されることにも注意してください。

as constとテンプレート文字列リテラル



テンプレート文字列リテラルに対して、
as const を使うと、特殊な効果を持ちます。

            const world: string = "world";

// string型
const str1 = `Hello, ${world}!`;

// `Hello, ${string}!` 型
const str2 = `Hello, ${world}!` as const;

        

上の例で、テンプレートリテラルは通常、string型となります。
これが、
as const が適用された場合、テンプレートリテラル型が得られます。
以上、
as const をリテラルにつけたときに推論される型の挙動をまとめると、以下のようになります。

            1. 文字列・数値・ブール値リテラルに作用させると、それ自体のリテラル型として推論される
2. テンプレート文字列リテラルに作用させると、テンプレートリテラル型に推論される
3. オブジェクトリテラルに作用させると、各プロパティがreadonlyを持つ
4. 配列リテラルに作用させると、readonlyタプル型になる

        

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

まとめ



以上、今回はTypescriptのオブジェクト型にまつわる応用的・実践的なテクニックを中心にまとめていきました。
ここまで一通り読んでもらうと、普段なんとなく使っているTypescriptの型システムの背景をじっくりと考えなおすことができたかもしれません。
次回からは、Conditional typeや、Mapped typeなど、Typescript界隈では「型パズル」や「型チャレンジ」などと言われて、競技性(?)が高くなってきた比較的新しい構文の内容を特集していく予定です。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

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

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