🧩

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

8 min read 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>];

コンストラクタ引数を厳格にするために,以下の「余分なキーが無いか」のチェックを併用します。

export type Exact<T, Shape> = T extends Shape
  ? Exclude<keyof T, keyof Shape> extends never
    ? T
    : never
  : never;

使い方

// プロフィール作成に必要な項目
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 で動作確認

トンランスパイル後に JavaScript から利用されることも想定する場合,雑にアサーションせずに丁寧に実行時の型チェックも書くほうがより安心です。

public create(...args: RequireMissing<Profile, O>): Profile;
public create(options?: Partial<Profile>): Profile {
  const name = options?.name ?? this.name;
  if (name === null) {
    throw new Error('Missing parameter: name');
  }

  const age = options?.age ?? this.age;
  if (age === null) {
    throw new Error('Missing parameter: age');
  }

  const url = options?.url ?? this.url;
  const description = options?.description ?? this.description;
  
  return { name, age, url, description };
}

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