Repository: alloy-framework/alloy
Pull request #67 (2025-03-20 05:06:06)

Add symbol metadata, make a few symbol-related cleanups
This PR adds an arbitrary bag of metadata for symbols which can be useful to e.g. drive more complicated name conflict resolution logic. An example of such logic is included in the test.

Additionally, this PR introduces a few (relatively minor) breaking changes in order to clean up various components and logic around symbols:

1. The notion of a default refkey for declarations has been completely removed. The previous behavior was confusing in the presence of naming policy and conflict resolution, super dangerous when multiple same-named symbols exist across your emit, and arguably only useful for tests. If you need a refkey for declarations you can manually add `refkey={refkey(name)}` but this is not recommended.
2. DeclarationProps, MemberDeclarationProps, and ScopeProps are now unions which allow the type system to check that you either provide a symbol/scope, or else all the required data to create a symbol/scope. As such anyone extending these interfaces will need to update their declaration (internally this was only C#, though it turned out it wasn't needed there anyway).
3. TypeScript ParameterDescriptor now carries the name, and components expect an array of descriptors instead of Record<string, ParameterDescriptor>. The record form is dangerous because you have to ensure you have no name conflicts, otherwise you'll silently lose parameters. The `Record<string, children>` signature remains though arguably it should also be removed for the same reasons.

Non-breaking changes:
* Scope name is now actually optional (previously it was sometimes cast to any to avoid providing a name).
* Add missing docs that I came across.

github-actions[bot]: <!--chronus-github-change-commenter-->
:x: There is undocummented changes. Run `chronus add` to add a changeset or [click here](https://github.com/bterlson/alloy/new/add-symbol-metadata?filename=.chronus/changes/add-symbol-metadata-2025-2-20-5-6-39.md&value=---%0A%23%20Change%20versionKind%20to%20one%20of%3A%20breaking%2C%20feature%2C%20fix%2C%20internal%0AchangeKind%3A%20fix%0Apackages%3A%0A%20%20-%20%22%40alloy-js%2Fcore%22%0A%20%20-%20%22%40alloy-js%2Fcsharp%22%0A%20%20-%20%22%40alloy-js%2Ftypescript%22%0A---%0A%0AAdd%20symbol%20metadata%2C%20make%20a%20few%20symbol-related%20cleanups).

**The following packages have changes but are not documented.**
 - :x:`@alloy-js/core`
 - :x:`@alloy-js/csharp`
 - :x:`@alloy-js/typescript`

<details>
<summary>Show changes</summary>


</details>


Patch:
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-41-22.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-41-22.md
new file mode 100644
index 0000000..a181e7f
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-41-22.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add metadata record to symbols and scopes for applications to store arbitrary information about them. Various declaration and scope forms have an additional prop to set this metadata.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-44-43.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-44-43.md
new file mode 100644
index 0000000..3885e81
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-44-43.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/core"
+---
+
+Refkeys no longer default to `refkey(name)`. This behavior is just not very useful and leads to some very confusing behavior especially when multiple same-named symbols exist within a single emit. The old behavior can be achieved by passing the `refkey={refkey(name)}` prop, but this is not recommended.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-46-33.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-46-33.md
new file mode 100644
index 0000000..fe72283
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-46-33.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/core"
+---
+
+DeclarationProps, MemberDeclarationProps, and ScopeProps are now unions in order to properly type check and document the distinct usages of either passing a symbol or props necessary to construct a symbol. Anyone extending these interfaces will need to make a similar split.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-47-6.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-47-6.md
new file mode 100644
index 0000000..f35b374
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-47-6.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Scope name is now optional. Many scopes don't have names.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-48-33.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-48-33.md
new file mode 100644
index 0000000..c5a4647
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-48-33.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/csharp"
+---
+
+Removed all default refkeys based on declaration name.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-48-49.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-48-49.md
new file mode 100644
index 0000000..023d0de
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-48-49.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/typescript"
+---
+
+Removed all default refkeys based on declaration name.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-51-40.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-51-40.md
new file mode 100644
index 0000000..fc5f8e4
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-51-40.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/typescript"
+---
+
+The `parameters` prop passed to various function or method defining components now takes a `ParameterDescriptor[]` instead of a `Record<string, ParameterDescriptor>`. `ParameterDescriptor` now has a required `name` property. The record form is dangerous because you have to ensure you have no name conflicts, otherwise you'll silently lose parameters. The `Record<string, Children>` variant remains though may be removed in a future version.
\ No newline at end of file
diff --git a/.chronus/changes/add-symbol-metadata-2025-2-23-13-54-31.md b/.chronus/changes/add-symbol-metadata-2025-2-23-13-54-31.md
new file mode 100644
index 0000000..6bb1008
--- /dev/null
+++ b/.chronus/changes/add-symbol-metadata-2025-2-23-13-54-31.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Expose `metadata` prop on various declaration forms to add arbitrary metadata about the symbol being declared. This metadata is stored on the symbol and can be accessed within e.g. name conflict resolution callbacks.
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index cb8486e..8c3800c 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -127,6 +127,11 @@ export interface OutputSymbol {
    * one static member symbol in the output (i.e., the symbol is unique).
    */
   staticMemberScope?: OutputScope;
+
+  /**
+   * Additional custom metadata about this symbol.
+   */
+  metadata: Record<string, unknown>;
 }
 
 /**
@@ -175,7 +180,7 @@ export interface OutputScope {
    * The kind of scope. Subtypes will likely provide a set of known scope kinds.
    * The kind is not used by the binder itself.
    */
-  kind: string;
+  kind?: string;
 
   /**
    * The name of the scope.
@@ -230,6 +235,7 @@ export type CreateSymbolOptions<T extends OutputSymbol = OutputSymbol> = {
   refkey?: Refkey;
   refkeys?: Refkey[];
   flags?: OutputSymbolFlags;
+  metadata?: Record<string, unknown>;
 } & Omit<T, keyof OutputSymbol>;
 
 export type CreateScopeOptions<T extends OutputScope = OutputScope> = {
@@ -238,6 +244,7 @@ export type CreateScopeOptions<T extends OutputScope = OutputScope> = {
   parent?: OutputScope | undefined;
   flags?: OutputScopeFlags;
   owner?: OutputSymbol;
+  metadata?: Record<string, unknown>;
 } & Omit<T, keyof OutputScope>;
 
 /**
@@ -463,6 +470,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       parent,
       owner,
       flags = OutputScopeFlags.None,
+      metadata = {},
       ...rest
     } = args;
 
@@ -495,6 +503,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       flags,
       owner,
       binder,
+      metadata,
       ...rest,
       getSymbolNames: symbolNames(symbols),
     }) as T;
@@ -523,6 +532,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       refkey,
       refkeys,
       flags = OutputSymbolFlags.None,
+      metadata = {},
       ...rest
     } = args;
 
@@ -566,6 +576,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       refkeys: allRefkeys,
       binder,
       flags,
+      metadata,
       ...rest,
     }) as T;
 
diff --git a/packages/core/src/components/Declaration.tsx b/packages/core/src/components/Declaration.tsx
index d8baef0..84f16f2 100644
--- a/packages/core/src/components/Declaration.tsx
+++ b/packages/core/src/components/Declaration.tsx
@@ -3,15 +3,53 @@ import { useContext } from "../context.js";
 import { BinderContext } from "../context/binder.js";
 import { DeclarationContext } from "../context/declaration.js";
 import { Children, onCleanup } from "../jsx-runtime.js";
-import { Refkey, refkey } from "../refkey.js";
+import { Refkey } from "../refkey.js";
 
-export interface DeclarationProps {
-  name?: string;
+/**
+ * Create a declaration by providing an already created symbol. The symbol is
+ * merely exposed via {@link DeclarationContext}.
+ */
+export interface DeclarationPropsWithSymbol {
+  /**
+   * The symbol being declared. When provided, the name, refkey, and metadata
+   * props are ignored.
+   */
+  symbol: OutputSymbol;
+
+  children?: Children;
+}
+
+/**
+ * Create a declaration by providing a symbol name and optional symbol metadata.
+ */
+export interface DeclarationPropsWithInfo {
+  /**
+   * The name of this declaration.
+   */
+  name: string;
+
+  /**
+   * The unique key for this declaration.
+   */
   refkey?: Refkey;
-  symbol?: OutputSymbol;
+
+  /**
+   * Multiple unique keys for this declaration.
+   */
+  refkeys?: Refkey[];
+
+  /**
+   * Additional metadata for the declared symbol.
+   */
+  metadata?: Record<string, unknown>;
+
   children?: Children;
 }
 
+export type DeclarationProps =
+  | DeclarationPropsWithSymbol
+  | DeclarationPropsWithInfo;
+
 /**
  * Declares a symbol in the current scope for this component's children.
  *
@@ -20,11 +58,10 @@ export interface DeclarationProps {
  * This component must be called in one of two ways: with a name and an optional
  * refkey, or else passing in the symbol. When called with a name and refkey, a
  * symbol will be created in the current scope (provided by
- * {@link ScopeContext}) with that name and refkey. If a refkey is not provided,
- * `refkey(props.name)` is used.
+ * {@link ScopeContext}) with that name and refkey.
  *
  * When called with a symbol, that symbol is merely exposed via
- * {@link DeclarationContext }. It is assumed that the caller of this component
+ * {@link DeclarationContext}. It is assumed that the caller of this component
  * has created the symbol with the `createSymbol` API on the
  * {@link BinderContext }.
  *
@@ -37,13 +74,14 @@ export function Declaration(props: DeclarationProps) {
   }
 
   let declaration;
-  if (props.symbol) {
+  if ("symbol" in props) {
     declaration = props.symbol;
   } else {
-    const rk = props.refkey ? props.refkey : refkey(props.name);
     declaration = binder.createSymbol({
       name: props.name!,
-      refkey: rk,
+      refkey: props.refkey,
+      refkeys: props.refkeys,
+      metadata: props.metadata,
     });
 
     onCleanup(() => {
diff --git a/packages/core/src/components/MemberDeclaration.tsx b/packages/core/src/components/MemberDeclaration.tsx
index 37e4247..fdf6012 100644
--- a/packages/core/src/components/MemberDeclaration.tsx
+++ b/packages/core/src/components/MemberDeclaration.tsx
@@ -3,16 +3,59 @@ import { useContext } from "../context.js";
 import { BinderContext } from "../context/binder.js";
 import { MemberDeclarationContext } from "../context/member-declaration.js";
 import { Children } from "../jsx-runtime.js";
-import { Refkey, refkey } from "../refkey.js";
+import { Refkey } from "../refkey.js";
 
-export interface MemberDeclarationProps {
-  name?: string;
+/**
+ * Create a member declaration by providing a symbol name and optional symbol
+ * metadata.
+ */
+export interface MemberDeclarationPropsWithInfo {
+  /**
+   * The name of this declaration.
+   */
+  name: string;
+
+  /**
+   * The unique key for this declaration.
+   */
   refkey?: Refkey;
-  symbol?: OutputSymbol;
-  children?: Children;
+
+  /**
+   * Multiple unique keys for this declaration.
+   */
+  refkeys?: Refkey[];
+
+  /**
+   * Additional metadata for the declared symbol.
+   */
+  metadata?: Record<string, unknown>;
+
+  /**
+   * Whether this is a static member. If not provided, the member is an instance
+   * member.
+   */
   static?: boolean;
+  children?: Children;
 }
 
+/**
+ * Create a declaration by providing an already created symbol. The symbol is
+ * merely exposed via {@link DeclarationContext}.
+ */
+export interface MemberDeclarationPropsWithSymbol {
+  /**
+   * The symbol being declared. When provided, the name, refkey, and metadata
+   * props are ignored.
+   */
+  symbol: OutputSymbol;
+
+  children?: Children;
+}
+
+export type MemberDeclarationProps =
+  | MemberDeclarationPropsWithInfo
+  | MemberDeclarationPropsWithSymbol;
+
 /**
  * Declares a symbol in the current member scope for this component's children.
  *
@@ -38,20 +81,22 @@ export function MemberDeclaration(props: MemberDeclarationProps) {
   }
 
   let declaration;
-  if (props.symbol) {
+  if ("symbol" in props && props.symbol) {
     declaration = props.symbol;
   } else {
-    if (!props.name) {
+    const infoProps = props as MemberDeclarationPropsWithInfo;
+    if (!infoProps.name) {
       throw new Error(
         "Must provide a member name, or else provide a member symbol",
       );
     }
-    const rk = props.refkey ? props.refkey : refkey(props.name);
     declaration = binder.createSymbol({
-      name: props.name!,
-      refkey: rk,
+      name: infoProps.name!,
+      refkey: infoProps.refkey,
+      refkeys: infoProps.refkeys,
+      metadata: infoProps.metadata,
       flags:
-        props.static ?
+        infoProps.static ?
           OutputSymbolFlags.StaticMember
         : OutputSymbolFlags.InstanceMember,
     });
diff --git a/packages/core/src/components/MemberScope.tsx b/packages/core/src/components/MemberScope.tsx
index a83d4e6..f3ddc8f 100644
--- a/packages/core/src/components/MemberScope.tsx
+++ b/packages/core/src/components/MemberScope.tsx
@@ -28,6 +28,8 @@ export interface MemberScopeProps {
  *
  * The member scope contains scopes for both instance and static members.
  * However, it does not affect the resolution of static members.
+ *
+ * @see {@link (MemberScopeContext:variable)}
  */
 export function MemberScope(props: MemberScopeProps) {
   const context: MemberScopeContext = {
diff --git a/packages/core/src/components/Scope.tsx b/packages/core/src/components/Scope.tsx
index 831f333..a807537 100644
--- a/packages/core/src/components/Scope.tsx
+++ b/packages/core/src/components/Scope.tsx
@@ -4,21 +4,61 @@ import { BinderContext } from "../context/binder.js";
 import { ScopeContext } from "../context/scope.js";
 import { Children } from "../jsx-runtime.js";
 
-export interface ScopeProps {
+/**
+ * Declare a scope by providing an already created scope. The scope is merely
+ * exposed via {@link ScopeContext}.
+ */
+export interface ScopePropsWithValue {
+  /**
+   * The scope to use. If not provided, a new scope will be created.
+   */
+  value: OutputScope;
+
+  children?: Children;
+}
+
+/**
+ * Create a scope by providing a name and optional metadata.
+ */
+export interface ScopePropsWithInfo {
+  /**
+   * The kind of scope. This may be used by application code to determine how
+   * to handle symbols in this scope. It is not used by the core framework.
+   */
   kind?: string;
+
+  /**
+   * The name of this scope.
+   */
   name?: string;
-  value?: OutputScope;
+
+  /**
+   * Additional metadata for the scope.
+   */
+  metadata?: Record<string, unknown>;
+
   children?: Children;
 }
 
+export type ScopeProps = ScopePropsWithValue | ScopePropsWithInfo;
+
+/**
+ * Declare a scope for this component's children. Any symbols and scopes
+ * declared in the children of this component will be in this scope.
+ *
+ * @see {@link ScopeContext}
+ */
 export function Scope(props: ScopeProps) {
   let scope: OutputScope;
-  if (props.value) {
+  if ("value" in props) {
     scope = props.value;
   } else {
-    const kind = props.kind ?? "file";
     const binder = useContext(BinderContext)!;
-    scope = binder.createScope({ kind, name: props.name! });
+    scope = binder.createScope({
+      kind: props.kind,
+      metadata: props.metadata,
+      name: props.name ?? "",
+    });
   }
 
   return (
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index a14483b..149b693 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -294,27 +294,131 @@ export function isComponentCreator(item: unknown): item is ComponentCreator {
 // eslint-disable-next-line @typescript-eslint/no-namespace
 export namespace JSX {
   export interface IntrinsicElements {
+    /**
+     * Attempt to render the children on a single line if possible. If a group
+     * contains `<breakParent />` or a hard line, or if the group exceeds the
+     * print width, all linebreaks in the group will be broken.
+     */
     group: { shouldBreak?: boolean; id?: symbol; children: Children };
+
+    /**
+     * A regular line break. This will break if the line exceeds the print
+     * width, otherwise it will be a space.
+     */
     line: {};
+
+    /**
+     * A regular line break. This will break if the line exceeds the print
+     * width, otherwise it will be a space.
+     */
     br: {};
+
+    /**
+     * A hard line break. This is a line that will always break, even if the
+     * group does not exceed print width.
+     */
     hardline: {};
+
+    /**
+     * A hard line break. This is a line that will always break, even if the
+     * group does not exceed print width.
+     */
     hbr: {};
+
+    /**
+     * A soft line break. This will break if the line exceeds the print width,
+     * otherwise it will be be nothing.
+     */
     softline: {};
+
+    /**
+     * A soft line break. This will break if the line exceeds the print width,
+     * otherwise it will be be nothing.
+     */
     sbr: {};
+
+    /**
+     * A literal line break. This will always break, even if the group does not
+     * exceed print width. The new line will ignore indentation.
+     */
     literalline: {};
+
+    /**
+     * A literal line break. This will always break, even if the group does not
+     * exceed print width. The new line will ignore indentation.
+     */
     lbr: {};
+
+    /**
+     * Increase the indentation level of the children of this component.
+     * Indentation is determined by the print options provided to the Output
+     * component or source file.
+     */
     indent: { children: Children };
+
+    /**
+     * Indent the children of this component if the group specified by `groupId`
+     * is broken (or not broken if `negate` is passed). The specified group must
+     * already be printed.
+     */
     indentIfBreak: { children: Children; groupId: symbol; negate?: boolean };
+
+    /**
+     * Similar to `group`, but will only place a line break before the last
+     * segment to exceed the print width. This is useful for formatting
+     * paragraphs of text where breaks are inserted prior to words which would
+     * otherwise exceed the print width.
+     */
     fill: { children: Children };
+
+    /**
+     * Force the parent group to break.
+     */
     breakParent: {};
+
+    /**
+     * Print children if the current group or already printed group specified by
+     * `groupId` is broken. Otherwise, `flatContents` is printed instead.
+     */
     ifBreak: { children: Children; flatContents?: Children; groupId?: symbol };
+
+    /**
+     * Print this content at the end of the line. Useful for things like line
+     * comments.
+     */
     lineSuffix: { children: Children };
+
+    /**
+     * Force any line suffixes to print at this point.
+     */
     lineSuffixBoundary: {};
+
+    /**
+     * Decrease the indentation level of the children of this component.
+     * Indentation is determined by the print options provided to the Output
+     * component or source file.
+     */
     dedent: { children: Children };
+
+    /**
+     * Indent the children of this component by either the number of characters
+     * indicated by the `width` prop, or by the provided string indicated by the
+     * `string` prop.
+     */
     align:
       | { children: Children; width: number }
       | { children: Children; string: string };
+
+    /**
+     * Mark the current indentation level as "root" for the purposes of literal
+     * line breaks and `dedentToRoot`.
+     */
     markAsRoot: { children: Children };
+
+    /**
+     * Decrease the indentation level to the root level specified by
+     * `<markAsRoot />`, or else to no indentation.
+     */
     dedentToRoot: { children: Children };
   }
   export type ElementType = string | ComponentDefinition<any>;
diff --git a/packages/csharp/src/components/Class.tsx b/packages/csharp/src/components/Class.tsx
index aac690e..bdf4e64 100644
--- a/packages/csharp/src/components/Class.tsx
+++ b/packages/csharp/src/components/Class.tsx
@@ -14,6 +14,7 @@ import { ParameterProps, Parameters } from "./Parameters.js";
 // properties for creating a class
 export interface ClassProps extends Omit<core.DeclarationProps, "nameKind"> {
   name: string;
+  refkey?: core.Refkey;
   accessModifier?: AccessModifier;
   typeParameters?: Record<string, core.Refkey>;
 }
@@ -24,9 +25,9 @@ export function Class(props: ClassProps) {
   const scope = useCSharpScope();
 
   const thisClassSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
-    name: name,
+    name,
     scope,
-    refkey: props.refkey ?? core.refkey(props.name),
+    refkey: props.refkey,
   });
 
   // this creates a new scope for the class definition.
@@ -165,9 +166,10 @@ export function ClassMember(props: ClassMemberProps) {
 }
 
 // properties for creating a method
-export interface ClassMethodProps
-  extends Omit<core.DeclarationProps, "nameKind"> {
+export interface ClassMethodProps {
   name: string;
+  refkey?: core.Refkey;
+  children?: core.Children;
   accessModifier?: AccessModifier;
   methodModifier?: MethodModifier;
   parameters?: Array<ParameterProps>;
diff --git a/packages/csharp/src/components/Enum.tsx b/packages/csharp/src/components/Enum.tsx
index b0598eb..b378dff 100644
--- a/packages/csharp/src/components/Enum.tsx
+++ b/packages/csharp/src/components/Enum.tsx
@@ -6,8 +6,10 @@ import { createCSharpMemberScope, useCSharpScope } from "../symbols/scopes.js";
 import { Name } from "./Name.jsx";
 
 // properties for creating an enum
-export interface EnumProps extends Omit<core.DeclarationProps, "nameKind"> {
+export interface EnumProps {
   name: string;
+  refkey?: core.Refkey;
+  children?: core.Children;
   accessModifier?: AccessModifier;
 }
 
diff --git a/packages/csharp/src/symbols/csharp-output-symbol.ts b/packages/csharp/src/symbols/csharp-output-symbol.ts
index baabfe0..76b0d76 100644
--- a/packages/csharp/src/symbols/csharp-output-symbol.ts
+++ b/packages/csharp/src/symbols/csharp-output-symbol.ts
@@ -1,4 +1,5 @@
 import * as core from "@alloy-js/core";
+import { DeclarationProps } from "../components/Declaration.jsx";
 import { useNamespace } from "../components/Namespace.jsx";
 import { CSharpOutputScope } from "./scopes.js";
 
@@ -8,7 +9,7 @@ export interface CSharpOutputSymbol extends core.OutputSymbol {
 }
 
 // creates a new C# symbol
-export function createCSharpSymbol(props: core.DeclarationProps) {
+export function createCSharpSymbol(props: DeclarationProps) {
   const scope = core.useScope() as CSharpOutputScope;
 
   const namespaceCtx = useNamespace();
diff --git a/packages/typescript/src/components/BlockScope.tsx b/packages/typescript/src/components/BlockScope.tsx
index 5a60e8b..e4eea95 100644
--- a/packages/typescript/src/components/BlockScope.tsx
+++ b/packages/typescript/src/components/BlockScope.tsx
@@ -1,7 +1,27 @@
-import { Block, BlockProps, Scope, ScopeProps } from "@alloy-js/core";
+import {
+  Block,
+  BlockProps,
+  Scope,
+  ScopePropsWithInfo,
+  ScopePropsWithValue,
+} from "@alloy-js/core";
 
-export interface BlockScopeProps extends BlockProps, ScopeProps {}
+export interface BlockScopePropsWithScopeValue
+  extends ScopePropsWithValue,
+    BlockProps {}
+export interface BlockScopePropsWithScopeInfo
+  extends ScopePropsWithInfo,
+    BlockProps {}
 
+export type BlockScopeProps =
+  | BlockScopePropsWithScopeValue
+  | BlockScopePropsWithScopeInfo;
+
+/**
+ * Create a TypeScript block which includes a scope for any nested declarations.
+ * Can either provide the scope directly via the `value` prop, or else provide
+ * information about the scope.
+ */
 export function BlockScope(props: BlockScopeProps) {
   return (
     <Scope {...props}>
diff --git a/packages/typescript/src/components/ClassDeclaration.tsx b/packages/typescript/src/components/ClassDeclaration.tsx
index ef5c115..e17a9b9 100644
--- a/packages/typescript/src/components/ClassDeclaration.tsx
+++ b/packages/typescript/src/components/ClassDeclaration.tsx
@@ -130,7 +130,7 @@ export function ClassField(props: ClassFieldProps) {
 
 export interface ClassMethodProps extends ClassMemberProps {
   async?: boolean;
-  parameters?: Record<string, Children | ParameterDescriptor>;
+  parameters?: Record<string, Children> | ParameterDescriptor[];
   returnType?: Children;
   children?: Children;
 }
diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx
index ee9959b..c2c578f 100644
--- a/packages/typescript/src/components/Declaration.tsx
+++ b/packages/typescript/src/components/Declaration.tsx
@@ -55,6 +55,11 @@ export interface BaseDeclarationProps {
    * value (e.g. var, const, let).
    */
   kind?: "type" | "value";
+
+  /**
+   * Arbitrary metadata about this declaration.
+   */
+  metadata?: Record<string, unknown>;
 }
 
 export interface DeclarationProps extends BaseDeclarationProps {
@@ -94,10 +99,10 @@ export function Declaration(props: DeclarationProps) {
     default: props.default,
     flags: props.flags,
     tsFlags,
+    metadata: props.metadata,
   });
 
   let children: Children;
-
   if (sym.flags & OutputSymbolFlags.MemberContainer) {
     children = <MemberScope owner={sym}>{props.children}</MemberScope>;
   } else {
diff --git a/packages/typescript/src/components/EnumDeclaration.tsx b/packages/typescript/src/components/EnumDeclaration.tsx
index 791c18e..258c9a5 100644
--- a/packages/typescript/src/components/EnumDeclaration.tsx
+++ b/packages/typescript/src/components/EnumDeclaration.tsx
@@ -35,6 +35,7 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
     default: props.default,
     export: props.export,
     flags: OutputSymbolFlags.StaticMemberContainer,
+    metadata: props.metadata,
   });
 
   const valueEntries = computed(() => Object.entries(props.jsValue ?? {}));
diff --git a/packages/typescript/src/components/EnumMember.tsx b/packages/typescript/src/components/EnumMember.tsx
index dbb20e5..48213af 100644
--- a/packages/typescript/src/components/EnumMember.tsx
+++ b/packages/typescript/src/components/EnumMember.tsx
@@ -27,6 +27,11 @@ export interface EnumMemberProps {
    * The JS value of the enum member.
    */
   jsValue?: string | number;
+
+  /**
+   * Arbitrary symbol metadata for the enum member.
+   */
+  metadata?: Record<string, unknown>;
 }
 
 /**
@@ -41,6 +46,7 @@ export function EnumMember(props: EnumMemberProps) {
       name,
       refkey: props.refkey,
       flags: OutputSymbolFlags.StaticMember,
+      metadata: props.metadata,
     });
   }
   const nameCode = sym ? sym.name : name;
diff --git a/packages/typescript/src/components/FunctionDeclaration.tsx b/packages/typescript/src/components/FunctionDeclaration.tsx
index 798f7ec..8558de5 100644
--- a/packages/typescript/src/components/FunctionDeclaration.tsx
+++ b/packages/typescript/src/components/FunctionDeclaration.tsx
@@ -22,24 +22,45 @@ import {
 } from "../symbols/index.js";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
 
+/**
+ * Information for a TypeScript function parameter.
+ */
 export interface ParameterDescriptor {
+  /**
+   * The name of the parameter.
+   */
+  name: string;
+
+  /**
+   * The type of the parameter.
+   */
   type?: Children;
+
+  /**
+   * The refkey for this parameter.
+   */
   refkey?: Refkey;
+
+  /**
+   * The refkeys for this parameter.
+   */
   refkeys?: Refkey[];
+
+  /**
+   * Whether the parameter is optional.
+   */
   optional?: boolean;
-}
 
-function isParameterDescriptor(
-  value: Children | ParameterDescriptor,
-): value is ParameterDescriptor {
-  return (
-    typeof value === "object" && value !== null && Object.hasOwn(value, "type")
-  );
+  /**
+   * Arbitrary metadata for the parameter symbol.
+   */
+  metadata?: Record<string, unknown>;
 }
+
 export interface FunctionDeclarationProps extends BaseDeclarationProps {
   async?: boolean;
-  parameters?: Record<string, Children | ParameterDescriptor>;
-  typeParameters?: Record<string, Children | ParameterDescriptor> | string[];
+  parameters?: Record<string, Children> | ParameterDescriptor[];
+  typeParameters?: Record<string, Children> | ParameterDescriptor[] | string[];
   returnType?: Children;
   children?: Children;
 }
@@ -98,55 +119,73 @@ function getReturnType(
   }
 }
 
-interface DeclaredParameterDescriptor extends ParameterDescriptor {
+interface DeclaredParameterDescriptor
+  extends Omit<ParameterDescriptor, "name"> {
   symbol: TSOutputSymbol;
 }
 
 function normalizeAndDeclareParameters(
-  parameters: Record<string, Children | ParameterDescriptor> | string[],
+  parameters: Record<string, Children> | ParameterDescriptor[] | string[],
   flags: TSSymbolFlags = TSSymbolFlags.ParameterSymbol,
 ): DeclaredParameterDescriptor[] {
   const namePolicy = useTSNamePolicy();
   if (Array.isArray(parameters)) {
-    return parameters.map((paramName) => {
-      const symbol = createTSSymbol({
+    if (parameters.length === 0) {
+      return [];
+    }
+    if (typeof parameters[0] === "string") {
+      return (parameters as string[]).map((paramName) => {
+        const symbol = createTSSymbol({
+          name: namePolicy.getName(
+            paramName,
+            flags & TSSymbolFlags.TypeSymbol ? "type" : "parameter",
+          ),
+          refkey: refkey(),
+          tsFlags: flags,
+        });
+
+        return { refkeys: symbol.refkeys, symbol };
+      });
+    } else {
+      return (parameters as ParameterDescriptor[]).map((param) => {
+        const symbol = createTSSymbol({
+          name: namePolicy.getName(
+            param.name,
+            flags & TSSymbolFlags.TypeSymbol ? "type" : "parameter",
+          ),
+          refkey: param.refkey ?? refkey(),
+          tsFlags: flags,
+          metadata: param.metadata,
+        });
+
+        return {
+          ...param,
+          symbol,
+        };
+      });
+    }
+  } else {
+    return Object.entries(parameters).map(([paramName, value]) => {
+      const descriptor: DeclaredParameterDescriptor = {
+        type: value,
+        symbol: undefined as any,
+      };
+
+      descriptor.symbol = createTSSymbol({
         name: namePolicy.getName(
           paramName,
           flags & TSSymbolFlags.TypeSymbol ? "type" : "parameter",
         ),
-        refkey: refkey(),
         tsFlags: flags,
       });
 
-      return { refkeys: symbol.refkeys, symbol };
+      return descriptor;
     });
   }
-
-  return Object.entries(parameters).map(([paramName, value]) => {
-    const descriptor: DeclaredParameterDescriptor =
-      isParameterDescriptor(value) ? value : (
-        ({
-          refkey: refkey(),
-          type: value,
-        } as any)
-      );
-
-    const symbol = createTSSymbol({
-      name: namePolicy.getName(
-        paramName,
-        flags & TSSymbolFlags.TypeSymbol ? "type" : "parameter",
-      ),
-      refkey: descriptor.refkey,
-      refkeys: descriptor.refkeys,
-      tsFlags: flags,
-    });
-    descriptor.symbol = symbol;
-    return descriptor;
-  });
 }
 
 export interface FunctionTypeParametersProps {
-  parameters?: Record<string, Children | ParameterDescriptor> | string[];
+  parameters?: Record<string, Children> | ParameterDescriptor[] | string[];
   children?: Children;
 }
 
@@ -200,7 +239,7 @@ function typeParameter(param: DeclaredParameterDescriptor) {
 }
 
 export interface FunctionParametersProps {
-  parameters?: Record<string, Children | ParameterDescriptor>;
+  parameters?: Record<string, Children> | ParameterDescriptor[];
   children?: Children;
 }
 
@@ -211,10 +250,10 @@ FunctionDeclaration.Parameters = taggedComponent(
       return props.children;
     }
 
-    const parameters = normalizeAndDeclareParameters(props.parameters ?? {});
+    const parameters = normalizeAndDeclareParameters(props.parameters ?? []);
     return (
       <group>
-        <Indent break="soft">
+        <Indent break="soft" trailingBreak>
           <For each={parameters} comma line>
             {(param) => parameter(param)}
           </For>
diff --git a/packages/typescript/src/components/VarDeclaration.tsx b/packages/typescript/src/components/VarDeclaration.tsx
index 0047ef0..c00bd2e 100644
--- a/packages/typescript/src/components/VarDeclaration.tsx
+++ b/packages/typescript/src/components/VarDeclaration.tsx
@@ -30,6 +30,7 @@ export function VarDeclaration(props: VarDeclarationProps) {
     refkeys: props.refkeys,
     default: props.default,
     export: props.export,
+    metadata: props.metadata,
   });
 
   const assignmentContext = createAssignmentContext(sym);
diff --git a/packages/typescript/src/symbols/ts-output-symbol.ts b/packages/typescript/src/symbols/ts-output-symbol.ts
index 5c14879..4b3d105 100644
--- a/packages/typescript/src/symbols/ts-output-symbol.ts
+++ b/packages/typescript/src/symbols/ts-output-symbol.ts
@@ -34,6 +34,7 @@ export interface createTsSymbolOptions {
   default?: boolean;
   flags?: OutputSymbolFlags;
   tsFlags?: TSSymbolFlags;
+  metadata?: Record<string, unknown>;
 }
 
 export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
@@ -55,6 +56,7 @@ export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
     default: !!options.default,
     flags: options.flags ?? OutputSymbolFlags.None,
     tsFlags: options.tsFlags ?? TSSymbolFlags.None,
+    metadata: options.metadata,
   });
 
   if (options.export && scope.kind === "module") {
diff --git a/samples/client-emitter/src/components/ClientMethod.tsx b/samples/client-emitter/src/components/ClientMethod.tsx
index edbff77..0feb4cf 100644
--- a/samples/client-emitter/src/components/ClientMethod.tsx
+++ b/samples/client-emitter/src/components/ClientMethod.tsx
@@ -12,21 +12,23 @@ export function ClientMethod(props: ClientMethodProps) {
   const op = props.operation;
 
   // get the parameters based on the spec's endpoint and requestBody
-  const parameters: Record<string, ts.ParameterDescriptor> = {};
+  const parameters: ts.ParameterDescriptor[] = [];
 
   const endpointParam = op.endpoint.match(/:(\w+)$/)?.[1];
   if (endpointParam) {
-    parameters[endpointParam] = {
+    parameters.push({
+      name: endpointParam,
       type: "string",
       refkey: refkey(op, endpointParam),
-    };
+    });
   }
 
   if (op.requestBody) {
-    parameters["body"] = {
+    parameters.push({
+      name: "body",
       type: refkey(apiContext.resolveReference(op.requestBody)),
       refkey: refkey(op, "requestBody"),
-    };
+    });
   }
 
   // get the return type based on the spec's responseBody.


Test patch:
diff --git a/packages/core/test/components/declaration.test.tsx b/packages/core/test/components/declaration.test.tsx
index d6d716f..621b955 100644
--- a/packages/core/test/components/declaration.test.tsx
+++ b/packages/core/test/components/declaration.test.tsx
@@ -19,7 +19,7 @@ it("creates and cleans up a symbol", () => {
   const template = (
     <Output>
       <GetBinder />
-      <Scope>
+      <Scope name="foo">
         {doDecl.value ?
           <Declaration name="foo"></Declaration>
         : ""}
diff --git a/packages/typescript/test/block-scope.test.tsx b/packages/typescript/test/block-scope.test.tsx
index 5d171ae..abe1f03 100644
--- a/packages/typescript/test/block-scope.test.tsx
+++ b/packages/typescript/test/block-scope.test.tsx
@@ -8,7 +8,7 @@ it("creates a scope", () => {
   const text = toSourceText(
     <>
       <VarDeclaration name="x" initializer="hi" />;<hbr />
-      <BlockScope>
+      <BlockScope name="foo">
         <VarDeclaration name="x" initializer="hello" />;
       </BlockScope>
     </>,
diff --git a/packages/typescript/test/class.test.tsx b/packages/typescript/test/class.test.tsx
index d9e570d..bccf792 100644
--- a/packages/typescript/test/class.test.tsx
+++ b/packages/typescript/test/class.test.tsx
@@ -163,10 +163,10 @@ describe("instance methods", () => {
           />
           <ts.ClassMethod
             name="six"
-            parameters={{
-              a: { type: "number", refkey: a },
-              b: { type: "number", refkey: b },
-            }}
+            parameters={[
+              { name: "a", type: "number", refkey: a },
+              { name: "b", type: "number", refkey: b },
+            ]}
             returnType="number"
           >
             {one} = {a} + {b};
diff --git a/packages/typescript/test/function-declaration.test.tsx b/packages/typescript/test/function-declaration.test.tsx
index baf3340..2d82c08 100644
--- a/packages/typescript/test/function-declaration.test.tsx
+++ b/packages/typescript/test/function-declaration.test.tsx
@@ -164,7 +164,7 @@ describe("symbols", () => {
       <>
         <FunctionDeclaration
           name="foo"
-          parameters={{ sym: { type: "any", refkey: rk } }}
+          parameters={[{ name: "sym", type: "any", refkey: rk }]}
         >
           <FunctionDeclaration name="bar">{rk}</FunctionDeclaration>
         </FunctionDeclaration>
@@ -198,13 +198,14 @@ describe("symbols", () => {
 
   it("create optional parameters", () => {
     const paramDesc: ParameterDescriptor = {
+      name: "foo",
       refkey: refkey(),
       type: "any",
       optional: true,
     };
     const decl = (
       <>
-        <FunctionDeclaration name="foo" parameters={{ foo: paramDesc }}>
+        <FunctionDeclaration name="foo" parameters={[paramDesc]}>
           console.log(foo);
         </FunctionDeclaration>
       </>
diff --git a/packages/typescript/test/name-conflict.test.tsx b/packages/typescript/test/name-conflict.test.tsx
new file mode 100644
index 0000000..6f7120e
--- /dev/null
+++ b/packages/typescript/test/name-conflict.test.tsx
@@ -0,0 +1,105 @@
+import { Output, render } from "@alloy-js/core";
+import { d } from "@alloy-js/core/testing";
+import { camelCase } from "change-case";
+import { expect, it } from "vitest";
+import {
+  FunctionDeclaration,
+  ParameterDescriptor,
+  SourceFile,
+  TSOutputSymbol,
+  TSSymbolFlags,
+} from "../src/index.js";
+it("handles custom name conflict resolver based on metadata", () => {
+  function resolver(name: string, symbols: TSOutputSymbol[]) {
+    const goodNamedSymbols = symbols.filter(
+      (s) => ~s.tsFlags & TSSymbolFlags.LocalImportSymbol,
+    );
+    const badNamedSymbols = symbols.filter(
+      (s) => s.tsFlags & TSSymbolFlags.LocalImportSymbol,
+    );
+    let nameCount = 1;
+
+    // always rename local import symbols (we don't care about them)
+    for (const sym of badNamedSymbols) {
+      sym.name = name + "_" + nameCount++;
+    }
+
+    // otherwise check the symbol metadata to see how to rename.
+
+    // group by symbol metadata
+    const groupedSymbols = new Map<string | undefined, TSOutputSymbol[]>([
+      ["body", []],
+      ["query", []],
+      ["header", []],
+      [undefined, []],
+    ]);
+    for (const sym of goodNamedSymbols) {
+      const sourceLocation = sym.metadata.sourceLocation as
+        | "body"
+        | "query"
+        | "header"
+        | undefined;
+      groupedSymbols.get(sourceLocation)!.push(sym);
+    }
+
+    for (const [sourceLocation, symbols] of groupedSymbols) {
+      let locationCount = 0;
+      if (sourceLocation === undefined) {
+        for (const sym of symbols) {
+          if (locationCount > 0) {
+            sym.name = name + locationCount;
+          }
+
+          locationCount++;
+        }
+      } else if (symbols.length < 0) {
+        continue;
+      } else if (symbols.length === 1) {
+        symbols[0].name = camelCase(name + "_" + sourceLocation);
+      } else {
+        for (const sym of symbols) {
+          sym.name = camelCase(
+            name + "_" + sourceLocation + locationCount++,
+            {},
+          );
+        }
+      }
+    }
+  }
+
+  const testParameters: ParameterDescriptor[] = [
+    { name: "foo" },
+    { name: "foo" },
+    { name: "foo", metadata: { sourceLocation: "body" } },
+    { name: "foo", metadata: { sourceLocation: "query" } },
+    { name: "foo", metadata: { sourceLocation: "query" } },
+    { name: "foo", metadata: { sourceLocation: "header" } },
+    { name: "foo", metadata: { sourceLocation: "header" } },
+    { name: "foo", metadata: { sourceLocation: "header" } },
+  ];
+
+  const res = render(
+    <Output nameConflictResolver={resolver}>
+      <SourceFile path="test.ts">
+        <FunctionDeclaration
+          export
+          name="conflicty"
+          parameters={testParameters}
+        />
+      </SourceFile>
+    </Output>,
+  );
+
+  expect(res.contents[0].contents).toBe(d`
+    export function conflicty(
+      foo,
+      foo1,
+      fooBody,
+      fooQuery0,
+      fooQuery1,
+      fooHeader0,
+      fooHeader1,
+      fooHeader2,
+    ) {}
+  `);
+});
diff --git a/packages/typescript/test/reference.test.tsx b/packages/typescript/test/reference.test.tsx
index 8b8d866..a4d2ab5 100644
--- a/packages/typescript/test/reference.test.tsx
+++ b/packages/typescript/test/reference.test.tsx
@@ -9,7 +9,9 @@ it("works with back references", () => {
   const res = render(
     <Output>
       <ts.SourceFile path="test1.ts">
-        <Declaration name="foo">const foo = 1;</Declaration>
+        <Declaration name="foo" refkey={refkey("foo")}>
+          const foo = 1;
+        </Declaration>
       </ts.SourceFile>
 
       <ts.SourceFile path="test2.ts">
@@ -37,7 +39,9 @@ it("works with forward references", () => {
         const v = <Reference refkey={refkey("foo")} />;
       </ts.SourceFile>
       <ts.SourceFile path="test1.ts">
-        <Declaration name="foo">const foo = 1;</Declaration>
+        <Declaration name="foo" refkey={refkey("foo")}>
+          const foo = 1;
+        </Declaration>
       </ts.SourceFile>
     </Output>,
   );