Repository: alloy-framework/alloy
Pull request #241 (2025-07-17 18:38:18)

core: Stringify event keys when logging event traces
This prevents a crash if the key is a symbol, which cannot be interpolated into a string template (but puzzlingly can be converted to a string using the `String` function).


Patch:
diff --git a/.chronus/changes/witemple-msft-interface-type-params-2025-6-7-19-18-6.md b/.chronus/changes/witemple-msft-interface-type-params-2025-6-7-19-18-6.md
new file mode 100644
index 0000000..51c8dcc
--- /dev/null
+++ b/.chronus/changes/witemple-msft-interface-type-params-2025-6-7-19-18-6.md
@@ -0,0 +1,9 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Implemented type parameters for InterfaceDeclaration components.
+
+Implemented type parameter defaults.
diff --git a/.chronus/changes/witemple-msft-no-space-case-default-2025-6-8-20-51-40.md b/.chronus/changes/witemple-msft-no-space-case-default-2025-6-8-20-51-40.md
new file mode 100644
index 0000000..c191ff1
--- /dev/null
+++ b/.chronus/changes/witemple-msft-no-space-case-default-2025-6-8-20-51-40.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/typescript"
+---
+
+Removed an errant space when using a `default` CaseClause.
\ No newline at end of file
diff --git a/.chronus/changes/witemple-msft-trace-stringify-2025-6-17-14-36-39.md b/.chronus/changes/witemple-msft-trace-stringify-2025-6-17-14-36-39.md
new file mode 100644
index 0000000..6808a5a
--- /dev/null
+++ b/.chronus/changes/witemple-msft-trace-stringify-2025-6-17-14-36-39.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/core"
+---
+
+Fixed an issue where event tracing would sometimes fail due to the presence of symbol properties.
\ No newline at end of file
diff --git a/packages/core/src/reactivity.ts b/packages/core/src/reactivity.ts
index 0b1f012..ceec54a 100644
--- a/packages/core/src/reactivity.ts
+++ b/packages/core/src/reactivity.ts
@@ -159,12 +159,12 @@ export function effect<T>(fn: (prev?: T) => T, current?: T) {
       scheduler: scheduler(() => runner),
       onTrack(event) {
         trace(TracePhase.effect.track, () => {
-          return `tracking ${event.target}, ${event.key}`;
+          return `tracking ${event.target}, ${String(event.key)}`;
         });
       },
       onTrigger(event) {
         trace(TracePhase.effect.trigger, () => {
-          return `triggering ${event.target}, ${event.key}`;
+          return `triggering ${event.target}, ${String(event.key)}`;
         });
       },
     },
diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx
index 7882967..53e4378 100644
--- a/packages/typescript/src/components/Declaration.tsx
+++ b/packages/typescript/src/components/Declaration.tsx
@@ -15,8 +15,6 @@ import { TypeDeclaration } from "./TypeDeclaration.jsx";
 import { PrivateScopeContext } from "../context/private-scope.js";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { EnumDeclaration } from "./EnumDeclaration.jsx";
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-import { InterfaceDeclaration } from "./Interface.jsx";
 
 export interface BaseDeclarationProps {
   /**
@@ -84,7 +82,7 @@ export interface DeclarationProps extends Omit<BaseDeclarationProps, "name"> {
 /**
  * Create a TypeScript declaration. Generally, this component shouldn't be used
  * directly, and instead prefer components for specific declarations, e.g.
- * {@link EnumDeclaration}, {@link InterfaceDeclaration},
+ * {@link EnumDeclaration}, {@link (InterfaceDeclaration:function)},
  * {@link TypeDeclaration}, etc.
  *
  * @remarks
diff --git a/packages/typescript/src/components/FunctionBase.tsx b/packages/typescript/src/components/FunctionBase.tsx
index b959715..9707a43 100644
--- a/packages/typescript/src/components/FunctionBase.tsx
+++ b/packages/typescript/src/components/FunctionBase.tsx
@@ -288,6 +288,10 @@ function typeParameter(param: DeclaredTypeParameterDescriptor) {
           <TypeRefContext>{param.extends}</TypeRefContext>
         </indent>
       </Show>
+      <Show when={!!param.default}>
+        {" = "}
+        <TypeRefContext>{param.default}</TypeRefContext>
+      </Show>
     </group>
   );
 }
diff --git a/packages/typescript/src/components/Interface.tsx b/packages/typescript/src/components/Interface.tsx
index 164daf7..4a1de61 100644
--- a/packages/typescript/src/components/Interface.tsx
+++ b/packages/typescript/src/components/Interface.tsx
@@ -1,6 +1,9 @@
 import {
   Block,
   Children,
+  childrenArray,
+  findKeyedChild,
+  findUnkeyedChildren,
   MemberScope,
   Name,
   OutputSymbolFlags,
@@ -14,28 +17,42 @@ import { useTSNamePolicy } from "../name-policy.js";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
 import { JSDoc } from "./JSDoc.jsx";
 
+import { TypeParameterDescriptor } from "../parameter-descriptor.js";
+import { TypeParameters } from "./FunctionBase.jsx";
 import { MemberDeclaration } from "./MemberDeclaration.jsx";
 import { PropertyName } from "./PropertyName.jsx";
 import { ensureTypeRefContext } from "./TypeRefContext.jsx";
 
 export interface InterfaceDeclarationProps extends BaseDeclarationProps {
   extends?: Children;
+
+  /**
+   * The generic type parameters of the interface.
+   */
+  typeParameters?: TypeParameterDescriptor[] | string[];
 }
 
-/**
- * Create a TypeScript interface declaration.
- *
- * @remarks
- *
- * This component will declare a symbol for this interface. The `export` and
- * `default` boolean props determine whether and how this symbol is exported
- * from the package.
- */
-export const InterfaceDeclaration = ensureTypeRefContext(
+const _InterfaceDeclaration = ensureTypeRefContext(
   (props: InterfaceDeclarationProps) => {
+    const children = childrenArray(() => props.children);
+
+    const typeParametersChildren =
+      findKeyedChild(children, TypeParameters.tag) ?? undefined;
+
+    const sTypeParameters =
+      typeParametersChildren ?
+        <>
+          {"<"}
+          {typeParametersChildren}
+          {">"}
+        </>
+      : <TypeParameters parameters={props.typeParameters} />;
+
     const extendsPart = props.extends ? <> extends {props.extends}</> : "";
     const flags = OutputSymbolFlags.StaticMemberContainer;
 
+    const filteredChildren = findUnkeyedChildren(children);
+
     return (
       <>
         <Show when={Boolean(props.doc)}>
@@ -44,14 +61,30 @@ export const InterfaceDeclaration = ensureTypeRefContext(
         </Show>
         <Declaration {...props} nameKind="interface" flags={flags} kind="type">
           interface <Name />
+          {sTypeParameters}
           {extendsPart}{" "}
-          <InterfaceExpression>{props.children}</InterfaceExpression>
+          <InterfaceExpression>{filteredChildren}</InterfaceExpression>
         </Declaration>
       </>
     );
   },
 );
 
+/**
+ * Create a TypeScript interface declaration.
+ *
+ * @remarks
+ *
+ * This component will declare a symbol for this interface. The `export` and
+ * `default` boolean props determine whether and how this symbol is exported
+ * from the package.
+ */
+export function InterfaceDeclaration(props: InterfaceDeclarationProps) {
+  return _InterfaceDeclaration(props);
+}
+
+InterfaceDeclaration.TypeParameters = TypeParameters;
+
 export interface InterfaceExpressionProps {
   children?: Children;
 }
diff --git a/packages/typescript/src/components/SwitchStatement.tsx b/packages/typescript/src/components/SwitchStatement.tsx
index 0603cda..23306f1 100644
--- a/packages/typescript/src/components/SwitchStatement.tsx
+++ b/packages/typescript/src/components/SwitchStatement.tsx
@@ -51,7 +51,7 @@ export function CaseClause(props: CaseClauseProps) {
   return (
     <>
       <Switch>
-        <Match when={"default" in props && props.default}>default </Match>
+        <Match when={"default" in props && props.default}>default</Match>
         <Match else>
           case{" "}
           {"expression" in props ?


Test patch:
diff --git a/packages/typescript/test/interface.test.tsx b/packages/typescript/test/interface.test.tsx
index 22ec7c6..e499524 100644
--- a/packages/typescript/test/interface.test.tsx
+++ b/packages/typescript/test/interface.test.tsx
@@ -229,6 +229,76 @@ it("handles invalid identifier names", () => {
   `);
 });
 
+it("accepts type parameters by descriptors", () => {
+  const typeParams: ts.TypeParameterDescriptor[] = [
+    { name: "T", refkey: refkey() },
+    { name: "U", extends: "number", refkey: refkey() },
+    { name: "V", default: "object", refkey: refkey() },
+    { name: "W", extends: "string", default: '"test"', refkey: refkey() },
+  ];
+
+  const res = toSourceText(
+    <ts.InterfaceDeclaration name="Foo" typeParameters={typeParams}>
+      <ts.InterfaceMember name="member" type={typeParams[0].refkey} />;
+      <hbr />
+      <ts.InterfaceMember name="member2" type={typeParams[1].refkey} />;
+      <hbr />
+      <ts.InterfaceMember name="member3" type={typeParams[2].refkey} />;
+      <hbr />
+      <ts.InterfaceMember name="member4" type={typeParams[3].refkey} />;
+    </ts.InterfaceDeclaration>,
+  );
+
+  expect(res).toEqual(d`
+    interface Foo<T, U extends number, V = object, W extends string = "test"> {
+      member: T;
+      member2: U;
+      member3: V;
+      member4: W;
+    }
+  `);
+});
+
+it("accepts type parameters with extends", () => {
+  const res = toSourceText(
+    <ts.InterfaceDeclaration name="Foo" typeParameters={["T"]} extends="Bar">
+      <ts.InterfaceMember name="member" type="T" />;
+    </ts.InterfaceDeclaration>,
+  );
+
+  expect(res).toEqual(d`
+    interface Foo<T> extends Bar {
+      member: T;
+    }
+  `);
+});
+
+it("accepts type parameters children", () => {
+  const res = toSourceText(
+    <ts.InterfaceDeclaration name="Foo">
+      <ts.InterfaceDeclaration.TypeParameters>
+        T, U extends number, V = object, W extends string = "test"
+      </ts.InterfaceDeclaration.TypeParameters>
+      <ts.InterfaceMember name="member" type={"T"} />;
+      <hbr />
+      <ts.InterfaceMember name="member2" type="U" />;
+      <hbr />
+      <ts.InterfaceMember name="member3" type="V" />;
+      <hbr />
+      <ts.InterfaceMember name="member4" type="W" />;
+    </ts.InterfaceDeclaration>,
+  );
+
+  expect(res).toEqual(d`
+    interface Foo<T, U extends number, V = object, W extends string = "test"> {
+      member: T;
+      member2: U;
+      member3: V;
+      member4: W;
+    }
+  `);
+});
+
 describe("method members", () => {
   it("render basic", () => {
     expect(
diff --git a/packages/typescript/test/switch-statement.test.tsx b/packages/typescript/test/switch-statement.test.tsx
index 9c5c9ca..8bf57b1 100644
--- a/packages/typescript/test/switch-statement.test.tsx
+++ b/packages/typescript/test/switch-statement.test.tsx
@@ -43,7 +43,7 @@ it("handles various cases", () => {
         // block
         break;
       }
-      default :
+      default:
         // default
     }
   `);