diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts
index 67784658236..fb824457cdb 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts
@@ -112,21 +112,40 @@ export class TcbNativeFieldOp extends TcbOp {
checkUnsupportedFieldBindings(this.node, this.unsupportedBindingFields, this.tcb);
- const expectedType = new TcbExpr(this.getExpectedTypeFromDomNode(this.node));
- const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
+ const rawExpectedType = this.getExpectedTypeFromDomNode(this.node);
- // Create a variable with the expected type and check that the field value is assignable, e.g.
- // var t1 = null! as string | number; t1 = f().value()`.
- const id = new TcbExpr(this.tcb.allocateId());
- const assignment = new TcbExpr(`${id.print()} = ${value.print()}`);
- assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
+ if (rawExpectedType === null) {
+ // For text-like elements, use an invariant check on the value signal.
+ // WritableSignal is invariant in T, so assigning it to a union of structural types
+ // gives us exact type matching: only Field or Field are accepted.
+ const signal = extractFieldValueSignal(fieldBinding.value, this.tcb, this.scope);
+ const id = new TcbExpr(this.tcb.allocateId());
+ const unionType = new TcbExpr(
+ '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }',
+ );
+ const assignment = new TcbExpr(`${id.print()} = ${signal.print()}`);
+ assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
+
+ this.scope.addStatement(declareVariable(id, unionType));
+ this.scope.addStatement(assignment);
+ } else {
+ const expectedType = new TcbExpr(rawExpectedType);
+ const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
+
+ // Create a variable with the expected type and check that the field value is assignable, e.g.
+ // var t1 = null! as string | number; t1 = f().value()`.
+ const id = new TcbExpr(this.tcb.allocateId());
+ const assignment = new TcbExpr(`${id.print()} = ${value.print()}`);
+ assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
+
+ this.scope.addStatement(declareVariable(id, expectedType));
+ this.scope.addStatement(assignment);
+ }
- this.scope.addStatement(declareVariable(id, expectedType));
- this.scope.addStatement(assignment);
return null;
}
- private getExpectedTypeFromDomNode(node: TmplAstElement): string {
+ private getExpectedTypeFromDomNode(node: TmplAstElement): string | null {
if (node.name === 'textarea' || node.name === 'select') {
// `