Angularのinput.requiredと既定値で埋めた入力のどちらを選ぶか
Angularのinput.requiredと既定値で埋めた入力のどちらを選ぶか
必ず渡ってくる値を受ける入力には2つの書き方がある。
readonly userId = input.required<string>();
readonly userIdOrEmpty = input<string>('');下の書き方のほうが扱いやすく見える。string | undefined ではなく string が返り、?? を書く必要もない。親のテンプレートを直さなくても型が通る。
通るのは型であって、値ではない。@angular/core 22.2.1 の実装を読むと、2つの宣言は異なる初期値を持ち、異なる経路で失敗する。どちらを選ぶかは、失敗をいつ・どこで見たいかで決まる。
2つの宣言が持つ初期値
どちらも createInputSignal を通る。違うのは渡す初期値だけである。
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 は専用のシンボルである。
const REQUIRED_UNSET_VALUE = /* @__PURE__ */Symbol('InputSignalNode#UNSET');読み取り側は、このシンボルが残っているかどうかを見る。
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 を通る。
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 が判定する。
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 のままである。
@Component({
template: `<app-profile [userIdOrEmpty]="selected()?.id" />`,
})
export class ProfilePage {
readonly selected = signal<User | undefined>(undefined);
}受け手が userIdOrEmpty().toUpperCase() を書いていれば、ここで TypeError になる。既定値は、束縛そのものを書かなかった場合(<app-profile />)にだけ残る。
この差は、既定値を「入力が省略できることの表明」として読むか「値が無いときの安全な代替」として読むかの差である。実装が支えているのは前者だけである。
@Reviewer: `userIdOrEmpty` に既定値 `''` を置いていますが、親は `selected()?.id` を束縛しているため、選択前は既定値ではなく `undefined` が入ります。型が `string` なので受け手側で `undefined` を想定した分岐が入らず、`toUpperCase()` で落ちる形です。入力を `input.required()` にして、親側を `@if (selected())` で囲んでください。 コンパイル時の検査が届く範囲
input.required を選ぶと、親が束縛を書き忘れたことがビルドで分かる。診断は @angular/compiler-cli 22.2.1 にある。
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 は必須入力を知らない。
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つに決まる。
@Reviewer: `createComponent(PreviewCard)` のあと `setInput` を2つ呼んでいますが、`PreviewCard` の必須入力は3つあります。動的生成では NG8008 が出ないため、描画時に NG0950 になります。入力の列挙を `bindings` に移して、設定経路を1つにしてください。読み取りの位置が決める失敗の時期
必須入力は、束縛が書き込まれるまで番兵を持っている。書き込みが起きる位置は、インスタンスが作られた後である。
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 が来た場合も呼ばれる。
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 の実行は追跡の外にある。
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観点の「入出力契約」の群に並べてある。