TypeScriptでメソッドチェーンの戻り値に型が付くようにする
TypeScriptにおいて、this引数の型注釈ができる仕様と、引数の型と戻り値の型の関係を型引数によって表現する方法を用いて、また、クラスメソッドの戻り値の型注釈を詳細に付けるようにすることで、メソッドチェーンの戻り値に型が付くようになる。
TypeScriptでのthis引数の型注釈
JavaScriptでは、関数内でのthisが何を参照するのかが、関数を呼び出した際の文脈によって変わる。
TypeScriptでは、クラスメソッドのthis引数に型注釈を付けて、そのメソッドを呼び出す際の文脈を制限することができる。
例:thisがSomeClassになるような文脈でしか呼べないメソッドを定義している。
code:SomeClass1.ts
export class SomeClass {
public doSomethingAndReturnThis(this: SomeClass): SomeClass {
return this;
}
}
doSomethingAndReturnThisをthisがSomeClassにならない文脈で呼び出そうとすると、コンパイル時に型エラーが発生する。
code:SomeClass1.usage.ts
import { SomeClass } from './SomeClass1.ts';
// thisの型が不明でも正しく動作する関数しか受け付けないようにする。
const createRunner = (func: (this: unknown, ...args: never[]) => unknown) => {
// do something
}
const someInstance = new SomeClass();
const runner = createRunner(someInstance.doSomethindAndReturnThis);
// 型エラーが発生する。
// error TS2322: Type '(this: SomeClass) => SomeClass' is not assignable to type '(this: unknown, ...args: never[]) => unknown'.
参考にした資料
さとうゆうた「【TypeScript】 this のトリセツ(1.this パラメーター) #TypeScript - Qiita」
Microsoft「TypeScript: Documentation - More on Functions」
型引数を使うと、thisの型と戻り値の型が同じであることを表現することができる。また、その型引数がSomeClassの派生型であるように制約を付ければ、thisの型がSomeClassの派生型であっても構わないということを明示できる。
code:SomeClass2.ts
class SomeClass {
public doSomethingAndReturnThis<TThis extends SomeClass>(this: TThis): TThis {
return this;
}
}
さらに、自分自身を書き換えて返すメソッドや、自分自身をコピーして書き換えたものを返すメソッドの戻り値の型を、より詳細に注釈すれば、メソッドチェーンの戻り値の型を追跡することができる。
code:UserAccountNontyped.ts
// メソッドチェーンの戻り値の型が追跡不可能になるような、クラスやメソッドの定義の例
export class UserAccountNontyped {
public readonly id: string;
public readonly emailAddress: string;
// 引数の各フィールドの値を自分自身のフィールドに詰め替えるだけのコンストラクタ。
private constructor(params: FieldsOf<UserAccount>) {
this.id = params.id;
this.emailAddress = params.emailAddress;
}
public static from(params: FieldsOf<UserAccount>): UserAccountNontyped {
return new UserAccountNontyped(params);
}
public toEmailAddressUpdated(
this: UserAccountNontyped,
params: { readonly newEmailAddress: string },
): UserAccountNontyped {
return UserAccountNontyped.from({ ...this, emailAddress: params.newEmailAddress });
}
}
export class UserAccountNontypedRepository {
public getOneById<TId extends string>(
this: UserAccountNontypedRepository,
id: TId,
): Promise<UserAccountNontyped & { readonly id: TId }> {
// do something
}
}
code:UserAccountNontyped.usage.ts
// 使用例
import { UserAccountNontyped, UserAccountNontypedRepository } from './UserAccountNontyped.ts';
const userAccountNontypedRepository = new UserAccountNontypedRepository();
const updatedUserAccountNontyped = (
await userAccountNontypedRepository.getOneById('example')
).toEmailAddressUpdated({
newEmailAddress: 'new-example@example.com',
}) satisfies UserAccountNontyped & {
readonly id: 'example';
readonly emailAddress: 'new-example@example.com';
};
// 型エラーが発生する。
// error TS1360: Type 'UserAccountNontyped' does not satisfy the expected type 'UserAccountNontyped & { readonly id: "example"; readonly emailAddress: "new-example@example.com"; }'.
code:UserAccount.ts
// メソッドチェーンの戻り値の型が追跡可能になるような、クラスやメソッドの定義の例
export class UserAccount {
public readonly id: string;
public readonly emailAddress: string;
// 引数の各フィールドの値を自分自身のフィールドに詰め替えるだけのコンストラクタ。
private constructor(params: FieldsOf<UserAccount>) {
this.id = params.id;
this.emailAddress = params.emailAddress;
}
// UserAccountのコンストラクタと同様のインタフェースを持つメソッドだが、
// 戻り値の各フィールドの型が、引数の各フィールドの型と同じになるように型注釈を付けている。
// - 注意
// - FieldsOf<T>は、Tからメソッドや、値に関数をとるプロパティを取り除いた型。
// - OmitExtra<T, U>は、Uから、Tにはないフィールドやメソッドを取り除いた型。
// - TypedInstance<T, U>は、Tのインスタンスで、各フィールドの型がUの各フィールドと同じである型。
public static from<TParams extends FieldsOf<UserAccount>>(
this: unknown,
params: OmitExtra<FieldsOf<UserAccount>, TParams>,
): TypedInstance<UserAccount, TParams> {
return new UserAccount(params) as TypedInstance<UserAccount, TParams>;
}
// 戻り値がUserAccountのインスタンスであると同時に、
// 戻り値のemailAddressフィールドが、引数のnewEmailAddressフィールドの型と同じになるようにしている。
public toEmailAddressUpdated<
TParams extends { readonly newEmailAddress: TNewEmailAddress },
TThis extends UserAccount,
TNewEmailAddress extends string = string,
(
this: TThis,
params: TParams,
): TypedInstance<UserAccount, TThis & { readonly emailAddress: TParams'newEmailAddress' }> {
return UserAccount.from({ ...this, emailAddress: params.newEmailAddress });
}
}
export class UserAccountRepository {
public getOneById<TId extends string>(
this: UserAccountRepository,
id: TId,
): Promise<UserAccount & { readonly id: TId }> {
// do something
}
}
code:UserAccount.usage.ts
// 使用例
import { UserAccount, UserAccountRepository } from './UserAccount.ts';
const userAccountRepository = new UserAccountRepository();
const updatedUserAccount = (
await userAccountRepository.getOneById('example')
).toEmailAddressUpdated({ newEmailAddress: 'new-example@example.com' }) satisfies UserAccount & {
readonly id: 'example';
readonly emailAddress: 'new-example@example.com'
};
// 型エラーは発生しない。updatedUserAccountの各フィールドに型が付いている。
補足:FieldsOf<T>、OmitExtra<T, U>、TypedInstance<T, U>の作り方
まず、Tの、値がUであるプロパティのみを残した型を得られるPickByValue<T, U>(特定の型の値をもつプロパティのみを抽出するユーティリティ型)を作る。
code:type-utils.ts
/** Tの、値がUであるプロパティのみを残した型を得る。 */
type PickByValue<T, U> = Pick<T, { K in keyof T: TK extends U ? K : never }keyof T>;
{ [K in keyof T]: T[K] extends U ? K : never }[keyof T]について
{ [K in keyof T]: T[K] extends U ? K : never }
Tのプロパティと同じプロパティを持つ辞書。
T[プロパティ名]の型がUなら、プロパティ名と同じ値を持ち、そうでないならnever型の値を持つ辞書。
辞書の[keyof T]をとると、辞書の各プロパティの値のユニオン型が取れる。
上記の辞書の各プロパティの値のユニオン型を取ると、T[プロパティ名]の型がUなプロパティ名のユニオン型が取れる。
PickByValue<T, U>があると色々作れる。
code:type-utils.ts
/** Tから、メソッドや値に関数をとるプロパティを取り除いた型を得る。 */
type FieldsOf<T> = Omit<T, keyof PickByValue<T, (...args: never[]) => unknown>>;
/** UからTにはないフィールドを取り除いた型を得る。 */
export type OmitExtra<T, U> = Pick<U, Extract<keyof U, keyof T>>;
/** Tのインスタンスで、各フィールドの値の型が、Uの各フィールドの値の型と同じである型を得る。 */
type TypedInstance<T, U> = T & OmitExtra<FieldsOf<T>, U>;
補足:TypedInstance<T, U>を使うべき理由
ただのT & Uは「Tのインスタンスなのに、TにはなくUには存在するメソッドを持つ型」になる。
\`T & U\`型の値.Uにしか存在しないメソッド()を呼び出すコードを書くと、コンパイルは通る(型エラーが出ない)が実行時エラーになるという、TypeScriptとして無意味なコードになる。
したがって、Uから、Tにない余計なフィールドやメソッドを取り除く必要がある。