Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions renderers/web_core/src/v0_9/rendering/coercion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* Strict Type Coercion Utilities
*
* Implements the A2UI protocol's standard coercion rules to ensure
* consistent handling of null/undefined values and type conversions.
*
* Without central enforcement, component authors must manually handle
* these edge cases, leading to bugs like [object Object] appearing
* in text labels.
*/

/**
* Coerces any value to a string following A2UI protocol rules:
* - null/undefined → ""
* - objects → localized string representation (not "[object Object]")
* - other types → String(value)
*/
export function coerceToString(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "object") {
// Avoid "[object Object]" by using JSON.stringify for plain objects
// or calling toString for objects with custom implementations
if (Array.isArray(value)) {
return value.map(coerceToString).join(", ");
}
if (value instanceof Error) {
return value.message;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
Comment on lines +50 to +54
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The try...catch block for JSON.stringify is a good way to handle potential errors, but the fallback to String(value) can still result in "[object Object]" for objects with circular references. This contradicts the function's documentation and the PR's goal of eliminating "[object Object]".

To make the behavior clearer, you could update the comment to acknowledge this edge case. If avoiding "[object Object]" is a strict requirement, you might need to consider a more robust serialization approach that can handle circular references, though that might be out of scope for this change. For now, clarifying the comment would be a good improvement.

}
return String(value);
}

/**
* Coerces any value to a number following A2UI protocol rules:
* - null/undefined → 0
* - strings → parsed number (NaN becomes 0)
* - booleans → 1 for true, 0 for false
* - other types → Number(value)
*/
export function coerceToNumber(value: unknown): number {
if (value === null || value === undefined) {
return 0;
}
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
const result = Number(value);
return isNaN(result) ? 0 : result;
}

/**
* Coerces any value to a boolean following A2UI protocol rules:
* - "true" (case-insensitive) → true
* - non-zero numbers → true
* - null/undefined → false
* - other types → Boolean(value)
*/
export function coerceToBoolean(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "string") {
return value.toLowerCase() === "true" || value !== "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current logic for coercing a string to a boolean, value.toLowerCase() === "true" || value !== "", will evaluate to true for any non-empty string (e.g., "false" becomes true). This is because for any non-empty string, the value !== "" part of the condition is always true, making the entire expression true.

Given the function's goal of strict coercion and the JSDoc rule "true" (case-insensitive) → true, a stricter implementation is needed. To fix this, the logic should only check if the string is a case-insensitive match for "true".

Suggested change
return value.toLowerCase() === "true" || value !== "";
return value.toLowerCase() === "true";

}
if (typeof value === "number") {
return value !== 0;
}
return Boolean(value);
}

/**
* Coerces a value to a specific target type.
*/
export function coerceValue<T>(value: unknown, targetType: "string"): string;
export function coerceValue<T>(value: unknown, targetType: "number"): number;
export function coerceValue<T>(value: unknown, targetType: "boolean"): boolean;
export function coerceValue<T>(value: unknown, targetType: string): unknown {
switch (targetType) {
case "string":
return coerceToString(value);
case "number":
return coerceToNumber(value);
case "boolean":
return coerceToBoolean(value);
default:
return value;
}
}
Comment on lines +1 to +121
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This new file introduces important coercion logic but lacks unit tests to verify its correctness and prevent future regressions. The repository style guide requires tests for new code.

Please add a test file (e.g., coercion.test.ts) with unit tests for the new coercion functions. The tests should cover the cases mentioned in the PR description as well as other edge cases, such as:

  • coerceToString: null, undefined, objects with circular references.
  • coerceToBoolean: "false", empty string, and other non-"true" strings.
  • coerceToNumber: null, undefined, invalid strings.
References
  1. If there are code changes, code should have tests. (link)

1 change: 1 addition & 0 deletions renderers/web_core/src/v0_9/rendering/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
} from "../schema/common-types.js";
import { A2uiExpressionError } from "../errors.js";
import type { FunctionInvoker } from "../catalog/types.js";
import { coerceToString, coerceToNumber, coerceToBoolean } from "./coercion.js";

/**
* A contextual view of the main DataModel, serving as the unified interface for resolving
Expand Down