Angularのinput.requiredと既定値で埋めた入力のどちらを選ぶか

必ず渡ってくる値を受ける入力には2つの書き方がある。

2つの宣言
readonly userId = input.required<string>();
readonly userIdOrEmpty = input<string>('');

下の書き方のほうが扱いやすく見える。string | undefined ではなく string が返り、?? を書く必要もない。親のテンプレートを直さなくても型が通る。

通るのは型であって、値ではない。@angular/core 22.2.1 の実装を読むと、2つの宣言は異なる初期値を持ち、異なる経路で失敗する。どちらを選ぶかは、失敗をいつ・どこで見たいかで決まる。

2つの宣言が持つ初期値

どちらも createInputSignal を通る。違うのは渡す初期値だけである。

22.2.1 の input / input.required
function inputFunction(initialValue, opts) {
  ngDevMode && assertInInjectionContext(input);
  return createInputSignal(initialValue, opts);
}
function inputRequiredFunction(opts) {
  ngDevMode && assertInInjectionContext(input);
  return createInputSignal(REQUIRED_UNSET_VALUE, opts);
}

REQUIRED_UNSET_VALUE は専用のシンボルである。

22.2.1 の宣言
const REQUIRED_UNSET_VALUE = /* @__PURE__ */Symbol('InputSignalNode#UNSET');

読み取り側は、このシンボルが残っているかどうかを見る。

22.2.1 の createInputSignal(読み取り部)
function inputValueFn() {
  producerAccessed(node);
  if (node.value === REQUIRED_UNSET_VALUE) {
    let message = null;
    if (ngDevMode) {
      const name = options?.debugName ?? options?.alias;
      message = `Input${name ? ` "${name}"` : ''} is required but no value is available yet.`;
    }
    throw new RuntimeError(-950, message);
  }
  return node.value;
}

producerAccessed(node) が先に走るので、例外を投げた読み取りも依存として記録される。そして message の組み立ては ngDevMode の中にある。本番ビルドで同じ読み取りが起きたとき、画面に出るのは NG0950 という文字列だけで、どの入力かは分からない。入力名を本番の記録に残したいなら、debugName ではなく読み取り側の try/catch か、そもそも読む位置を直すことになる。

既定値を渡したほうには番兵が入らないため、この分岐を通らない。input<string>('') は、値が書かれる前から '' を返す。

既定値が使われる条件

既定値は「渡されなかったとき」の値である。「渡されたが undefined だったとき」の値ではない。親からの束縛は writeToDirectiveInput を通る。

22.2.1 の writeToDirectiveInput(抜粋)
const [privateName, flags, transform] = def.inputs[publicName];
let inputSignalNode = null;
if ((flags & InputFlags.SignalBased) !== 0) {
  const field = instance[privateName];
  inputSignalNode = field[SIGNAL];
}
if (inputSignalNode !== null && inputSignalNode.transformFn !== undefined) {
  value = inputSignalNode.transformFn(value);
} else if (transform !== null) {
  value = transform.call(instance, value);
}
if (def.setInput !== null) {
  def.setInput(instance, inputSignalNode, value, publicName, privateName);
} else {
  applyValueToInputField(instance, inputSignalNode, privateName, value);
}

value が undefined かどうかを見る分岐は無い。行き先の applyValueToInputSignal は signalSetFn(node, value) を呼ぶだけで、既定値へ戻す経路も無い。

残るのは「そもそも書き込みが起きるか」である。束縛の更新は bindingUpdated が判定する。

22.2.1 の bindingUpdated
function bindingUpdated(lView, bindingIndex, value) {
  if (value === NO_CHANGE) {
    return false;
  }
  const oldValue = lView[bindingIndex];
  if (Object.is(oldValue, value)) {
    return false;
  } else {
    // ...
    lView[bindingIndex] = value;
    return true;
  }
}

スロットの初期値は NO_CHANGE である。初回の更新では Object.is(NO_CHANGE, undefined) が偽になり、書き込みが起きる。つまり [userIdOrEmpty]="selected()?.id" のような束縛で selected() がまだ undefined なら、宣言した '' は初回の描画で undefined に上書きされる。型は string のままである。

型が通り、実行時に undefined が入る
@Component({
  template: `<app-profile [userIdOrEmpty]="selected()?.id" />`,
})
export class ProfilePage {
  readonly selected = signal<User | undefined>(undefined);
}

受け手が userIdOrEmpty().toUpperCase() を書いていれば、ここで TypeError になる。既定値は、束縛そのものを書かなかった場合(<app-profile />)にだけ残る。

この差は、既定値を「入力が省略できることの表明」として読むか「値が無いときの安全な代替」として読むかの差である。実装が支えているのは前者だけである。

Comment
@Reviewer: `userIdOrEmpty` に既定値 `''` を置いていますが、親は `selected()?.id` を束縛しているため、選択前は既定値ではなく `undefined` が入ります。型が `string` なので受け手側で `undefined` を想定した分岐が入らず、`toUpperCase()` で落ちる形です。入力を `input.required()` にして、親側を `@if (selected())` で囲んでください。

コンパイル時の検査が届く範囲

input.required を選ぶと、親が束縛を書き忘れたことがビルドで分かる。診断は @angular/compiler-cli 22.2.1 にある。

22.2.1 の missingRequiredInputs
missingRequiredInputs(id, element, directiveName, isComponent, inputAliases) {
  const message = `Required input${inputAliases.length === 1 ? "" : "s"} ${inputAliases.map((n2) => `'${n2}'`).join(", ")} from ${isComponent ? "component" : "directive"} ${directiveName} must be specified.`;
  this._diagnostics.push(makeTemplateDiagnostic(id, this.resolver.getTemplateSourceMapping(id), this.getTagNameSpan(element), ts32.DiagnosticCategory.Error, ngErrorCode(ErrorCode.MISSING_REQUIRED_INPUTS), message));
}

MISSING_REQUIRED_INPUTS は 8008 なので、出るのは NG8008 である。テンプレートの型検査として報告されるため、検査の対象になっているテンプレートに限られる。

対象から外れる経路が1つある。動的に作ったコンポーネントである。ComponentRef.setInput は必須入力を知らない。

22.2.1 の ComponentRef.setInput(抜粋)
const hasSetInput = setAllInputsForProperty(tNode, lView[TVIEW$1], lView, name, value);
this.previousInputValues.set(name, value);
const childComponentLView = getComponentLViewByIndex(tNode.index, lView);
markViewDirty(childComponentLView, 1);
if (ngDevMode && !hasSetInput) {
  const cmpNameForError = stringifyForError(this.componentType);
  let message = `Can't set value of the '${name}' input on the '${cmpNameForError}' component. `;
  message += `Make sure that the '${name}' property is declared as an input using the input() or model() function or the @Input() decorator.`;
  reportUnknownPropertyError(message);
}

確かめているのは「その名前の入力が存在するか」であって、「必須の入力が全部設定されたか」ではない。createComponent() で作って setInput を1つ呼び忘れると、NG8008 は出ず、描画されて初めて NG0950 になる。モーダルやツールチップを動的に差し込む実装は、ここで必須入力の保証を失う。

その代わりに、呼ぶ側が設定を揃えていることをテストで固定するか、createComponent() の bindings(inputBinding())で入力を列挙する。後者を使った場合は setInput が NG0317 で拒否されるので、設定経路が1つに決まる。

Comment
@Reviewer: `createComponent(PreviewCard)` のあと `setInput` を2つ呼んでいますが、`PreviewCard` の必須入力は3つあります。動的生成では NG8008 が出ないため、描画時に NG0950 になります。入力の列挙を `bindings` に移して、設定経路を1つにしてください。

読み取りの位置が決める失敗の時期

必須入力は、束縛が書き込まれるまで番兵を持っている。書き込みが起きる位置は、インスタンスが作られた後である。

22.2.1 の instantiateAllDirectives(抜粋)
const directive = getNodeInjectable(lView, tView, i, tNode);
attachPatchData(directive, lView);
if (initialInputs !== null) {
  setInputsFromAttrs(lView, i - start, directive, def, tNode, initialInputs);
}

getNodeInjectable がコンストラクターを走らせ、そのあとに静的な属性の入力が書かれる。束縛された入力は、さらに後の更新パスで書かれる。したがってフィールドの初期化子やコンストラクターで必須入力を読むと、どちらの経路でも番兵のままで NG0950 になる。

コンストラクターで読んでいる
export class UserBadge {
  readonly userId = input.required<string>();
  private readonly label = `user-${this.userId()}`; // NG0950
}

安全に読めるのは ngOnInit 以降か、computed と effect の内側である。computed は読まれた時点で計算されるので、テンプレートから読まれる時点では値が入っている。

派生として書く
readonly userId = input.required<string>();
readonly label = computed(() => `user-${this.userId()}`);

既定値を持つ入力なら、同じ位置で読んでも例外にならない。ただし読める値は宣言した既定値であって、親が渡す値ではない。初期化子で読んだ値を別のフィールドに保持すると、入力の更新が届かなくなる。入力を内部の signal へ写した場合に同じことが起きる。signal クエリが同様に undefined を返す経路はAngularのsignalクエリがundefinedを返す経路とレビュー観点で扱った。

つまり「例外が出ないほうが安全」とは言えない。必須入力の NG0950 は読み取り位置の誤りをその場で知らせ、既定値を持つ入力は同じ誤りを黙って通す。

transformを挟んだときの扱い

transform は書き込みのたびに呼ばれる。上の writeToDirectiveInput のとおり、undefined が来た場合も呼ばれる。

undefined も transform に渡る
readonly size = input(0, { transform: (v: number) => Math.max(0, v) });

親が [size]="maybeUndefined" を書けば Math.max(0, undefined) が走り、NaN が入る。既定値の 0 は使われない。undefined を受け付ける想定なら、transform の引数の型を number | undefined にして内側で埋める。

もう1つ、transform の実行は追跡の外にある。

22.2.1 の writeToDirectiveInput(前後)
const prevConsumer = setActiveConsumer(null);
try {
  // ... transform の呼び出しと書き込み
} finally {
  setActiveConsumer(prevConsumer);
}

setActiveConsumer(null) で囲われているため、transform の中で signal を読んでも依存として記録されない。読んだ signal が後で変わっても transform は再実行されず、入力の値は古い計算結果のままになる。transform は渡された値だけから結果を決める関数に保ち、他の状態を混ぜるなら computed に出す。

選択の基準

ここまでの機構から、2つの宣言の使い分けは次のように引ける。

値が無い状態が意味を持たないなら input.required<T>() を選ぶ。テンプレート経由の呼び出し元は NG8008 で捕まり、読み取り位置の誤りは NG0950 で捕まる。代わりに、親側は値が揃うまで @if で囲む責務を持つ。

既定値を渡すのは、入力を省略した呼び出しが実際に存在し、そのときの値が決まっている場合である。<app-pager /> と <app-pager [pageSize]="20" /> の両方を書くなら input(10) が合う。このとき、親が undefined になり得る式を束縛していないかを別に確かめる。確かめられないなら、型を input<number | undefined>() にして受け手で埋めるほうが、実行時の値と型が一致する。

どちらでもない書き方として、既定値を「とりあえず」の値で埋める形がある。input('') や input(0) が、省略した呼び出しのためではなく undefined を型から消すために置かれているなら、それは必須入力を宣言し忘れている。

レビュー観点チェックリスト

入力契約の宣言を確かめる項目
  • 既定値が、入力を省略した呼び出しのために置かれているか(型から undefined を消すためではないか)
  • 既定値を持つ入力に、親が undefined になり得る式を束縛していないか
  • 必須入力を、フィールドの初期化子やコンストラクターで読んでいないか
  • 必須入力から導く値が、初期化子での代入ではなく computed になっているか
  • 動的に作るコンポーネントで、必須入力の設定漏れを止める手段があるか(bindings かテスト)
  • transform の引数の型が、undefined が渡る可能性と一致しているか
  • transform の中で他の signal を読んでいないか(再実行されない)
  • 入力を内部の signal へ写して、親の更新が届かなくなっていないか
  • 必須入力を増やしたとき、既存の呼び出し元すべてがビルドで検査される位置にあるか
  • 本番の記録に入力名が要るなら、NG0950 を読み取り側で捕まえる手当があるか

おわりに

input.required<T>() は REQUIRED_UNSET_VALUE という番兵を初期値に置き、読まれた時点で NG0950 を投げる。本番ビルドではメッセージが落ち、番号だけが残る。この引き換えで得られるのは、親の書き忘れが NG8008 として、読み取り位置の誤りが NG0950 として、どちらも早く出ることである。

既定値を渡した入力は、束縛を書かなかった呼び出しに対してだけ既定値を返す。親が undefined になり得る式を束縛すれば、bindingUpdated が初回に書き込みを通し、宣言した既定値は上書きされる。型が string のまま値が undefined になるのはこの経路である。

したがって選択は「書きやすさ」ではなく、値が無い状態を型として認めるかどうかで決まる。認めないなら必須入力にして親に責務を渡す。認めるなら undefined を型に残して受け手で埋める。既定値は、省略できる入力を本当に省略する呼び出しがあるときだけ使う。

入出力契約の観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「入出力契約」の群に並べてある。