🧩

TypeScript で「コンストラクタで渡さなかった引数だけ後で必須にする」の型制約を書く

2021/07/09に公開
1

実装

/**
 * [α] T 型から undefined 不可キーの union を抽出
 */
type RequiredKeys<T> = {
  [K in keyof T]-?: Record<any, unknown> extends Pick<T, K>
    ? never
    : K;
}[keyof T];

/**
 * [β] α を利用し, Passed 型から Req 型の条件を満たさないものだけを抽出
 */
type MissingKeys<Req, Passed extends Partial<Req>> = {
  [K in keyof Pick<
    Req,
    RequiredKeys<Req>
  >]: Passed[K] extends Req[K] ? never : K;
}[keyof Pick<Req, RequiredKeys<Req>>];

/**
 * [γ] β を利用し, 「undefined 不可の不足しているサブセット」と「それ以外のサブセット」を抽出
 *     後者に対してのみ undefined を許可して型を合成する
 */
type RequireMissingSubset<Req, Passed extends Partial<Req>> = Pick<
  Req,
  MissingKeys<Req, Passed>
> &
  Partial<Omit<Req, MissingKeys<Req, Passed>>>;
  
/**
 * γ を利用し, 可変長リストで引数自体を省略可能か表現する
 */
export type RequireMissing<Req, Passed extends Partial<Req>> =
  Record<any, unknown> extends RequireMissingSubset<Req, Passed>
  ? [options?: RequireMissingSubset<Req, Passed>]
  : [options: RequireMissingSubset<Req, Passed>];

使い方

// プロフィール作成に必要な項目
type Profile = {
  name: string;         // 名前(*必須*)
  age: number;          // 年齢(*必須*)
  url?: string;         // URL(任意)
  description?: string; // 自己紹介(任意)
}

// ↓ コンストラクタから推論される型パラメータを与える
class ProfileFactory<O extends Partial<Profile>> {
  // ↓ すべて Nullable にしておく
  private readonly name: string|null;
  private readonly age: number|null;
  private readonly url: string|null;
  private readonly description: string|null;

  // ↓ 推論させる対象
  constructor(options?: Exact<O, Partial<Profile>>) {
    this.name = options?.name ?? null;
    this.age = options?.age ?? null;
    this.url = options?.url ?? null;
    this.description = options?.description ?? null;
  }

  // ↓ 作成時に渡されなかった必須オプションを RequireMissing を用いたオーバーロードで要求
  public create(...args: RequireMissing<Profile, O>): Profile;

  // ↓ 実際は省略可能な Partial<Profile> 型として受け取る
  public create(options?: Partial<Profile>): Profile {
    return {
      name: options?.name ?? this.name,
      age: options?.age ?? this.age,
      url: options?.url ?? this.url,
      description: options?.description ?? this.description,
    } as Profile; // ←アサーションが必要
  }
}

// 必須パラメータはファクトリで埋められている
const factory1 = new ProfileFactory({
  name: 'Bob',
  age: 20,
});
// 空で作れる
factory1.create({});
// 引数自体を省略しても作れる
factory1.create();
// 部分的に必須パラメータをオーバーライドしても作れる
factory1.create({
  name: 'Bob',
  url: 'https://example.com/',
});
// 全部指定しても作れる
factory1.create({
  name: 'Bob',
  age: 20,
  url: 'https://example.com/',
  description: 'hello',
});

// 必須パラメータがファクトリで不足
const factory2 = new ProfileFactory({
  age: 20,
});
// 足りないものを補えば作れる
factory2.create({
  name: 'Bob',
});
// 部分的にオプションパラメータをオーバーライドしても作れる
factory2.create({
  name: 'Bob',
  url: 'https://example.com/',
});
// 全部指定しても作れる
factory2.create({
  name: 'Bob',
  age: 20,
  url: 'https://example.com/',
  description: 'hello',
});
// 空では不可
factory2.create({});
// 引数自体を省略すると不可
factory2.create();

// ファクトリに何もパラメータが与えられていない
const factory3 = new ProfileFactory();
// 必須パラメータをすべて指定すれば作れる
factory3.create({
  name: 'Bob',
  age: 20,
});
// 全部指定しても作れる
factory3.create({
  name: 'Bob',
  age: 20,
  url: 'https://example.com/',
  description: 'hello',
});
// 必須パラメータが足りなければ不可
factory3.create({
  name: 'Bob',
  url: 'https://example.com/',
});
// 空では不可
factory3.create({});
// 引数自体を省略すると不可
factory3.create();

// 余分なパラメータを与えるとコンストラクタでエラー
const factory4 = new ProfileFactory({
  name: 'Bob',
  age: 20,
  foo: 123,
});

// 余分なパラメータは作成時にもエラー
const factory5 = new ProfileFactory({
  name: 'Bob',
  age: 20,
});
factory5.create({
  foo: 123,
});

Playground で動作確認

Special Thanks: @uhyo さん (改良に協力していただきました)

GitHubで編集を提案