Repository: alloy-framework/alloy
Pull request #74 (2025-04-02 22:10:21)

Implement call signature, clean up refkey, add defaultProps, clean up TS arguments
A batch of changes here:

* Implement a `CallSignature` component for TypeScript.
* As part of this, clean up the parameters and typeParameters types. NOTE: the record signatures are now gone completely.
* Add `defaultProps` to set defaults to props
* Remove plural `refkeys` anywhere it was added in lieu of just taking an array for the `refkey` prop.

github-actions[bot]: <!--chronus-github-change-commenter-->
All changed packages have been documented.
 - :white_check_mark: `@alloy-js/core`
 - :white_check_mark: `@alloy-js/json`
 - :white_check_mark: `@alloy-js/typescript`

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

### `@alloy-js/core` - _breaking_ [✏️](https://github.com/bterlson/alloy/edit/call-signature/.chronus/changes/call-signature-2025-3-2-15-4-5.md?pr=/alloy-framework/alloy/pull/74)
> Remove plural `refkeys` prop. Can now pass an array for the `refkey` prop.
### `@alloy-js/json` - _breaking_ [✏️](https://github.com/bterlson/alloy/edit/call-signature/.chronus/changes/call-signature-2025-3-2-15-4-5.md?pr=/alloy-framework/alloy/pull/74)
> Remove plural `refkeys` prop. Can now pass an array for the `refkey` prop.
### `@alloy-js/typescript` - _breaking_ [✏️](https://github.com/bterlson/alloy/edit/call-signature/.chronus/changes/call-signature-2025-3-2-15-4-5.md?pr=/alloy-framework/alloy/pull/74)
> Remove plural `refkeys` prop. Can now pass an array for the `refkey` prop.
### `@alloy-js/core` - _feature_ [✏️](https://github.com/bterlson/alloy/edit/call-signature/.chronus/changes/call-signature-2025-3-2-15-4-52.md?pr=/alloy-framework/alloy/pull/74)
> Add `defaultProps` to allow setting default prop values.
### `@alloy-js/typescript` - _feature_ [✏️](https://github.com/bterlson/alloy/edit/call-signature/.chronus/changes/call-signature-2025-3-2-15-6-10.md?pr=/alloy-framework/alloy/pull/74)
> Add CallSignature component for creating TypeScript call signatures. Suitable for placing inside an `InterfaceDeclaration` or `InterfaceExpression`.
### `@alloy-js/typescript` - _breaking_ [✏️](https://github.com/bterlson/alloy/edit/call-signature/.chronus/changes/call-signature-2025-3-2-15-8-50.md?pr=/alloy-framework/alloy/pull/74)
> Removed Record<string, Children> signature for `parameters` and `typeParameters` props. These now take an array of `ParameterDescriptor`s or `TypeParameterDescriptor`s respectively. They can also simply take an array of strings.
</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/bd79bb38-b2db-47cd-8cc1-d37703341cd5)


<details><summary><b>@alloy-js/babel-plugin</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-plugin-jsx-dom-expressions</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-plugin-jsx-dom-expressions@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/core</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/core@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/babel-preset</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/babel-preset@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/create</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/create@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/csharp</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/csharp@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/java</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/java@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/json</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/json@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/prettier-plugin-alloy</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/prettier-plugin-alloy@74
```

</p></details>
      
    

<details><summary><b>@alloy-js/typescript</b></summary><p>

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/typescript@74
```

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/39883980643"><code>9931c0c</code></a>_


Patch:
diff --git a/.chronus/changes/call-signature-2025-3-2-15-4-5.md b/.chronus/changes/call-signature-2025-3-2-15-4-5.md
new file mode 100644
index 0000000..e32ecc7
--- /dev/null
+++ b/.chronus/changes/call-signature-2025-3-2-15-4-5.md
@@ -0,0 +1,9 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/core"
+  - "@alloy-js/json"
+  - "@alloy-js/typescript"
+---
+
+Remove plural `refkeys` prop. Can now pass an array for the `refkey` prop.
\ No newline at end of file
diff --git a/.chronus/changes/call-signature-2025-3-2-15-4-52.md b/.chronus/changes/call-signature-2025-3-2-15-4-52.md
new file mode 100644
index 0000000..0ceaae9
--- /dev/null
+++ b/.chronus/changes/call-signature-2025-3-2-15-4-52.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add `defaultProps` to allow setting default prop values.
\ No newline at end of file
diff --git a/.chronus/changes/call-signature-2025-3-2-15-6-10.md b/.chronus/changes/call-signature-2025-3-2-15-6-10.md
new file mode 100644
index 0000000..f4aab9a
--- /dev/null
+++ b/.chronus/changes/call-signature-2025-3-2-15-6-10.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Add CallSignature component for creating TypeScript call signatures. Suitable for placing inside an `InterfaceDeclaration` or `InterfaceExpression`.
\ No newline at end of file
diff --git a/.chronus/changes/call-signature-2025-3-2-15-8-50.md b/.chronus/changes/call-signature-2025-3-2-15-8-50.md
new file mode 100644
index 0000000..a0fbca0
--- /dev/null
+++ b/.chronus/changes/call-signature-2025-3-2-15-8-50.md
@@ -0,0 +1,7 @@
+---
+changeKind: breaking
+packages:
+  - "@alloy-js/typescript"
+---
+
+Removed Record<string, Children> signature for `parameters` and `typeParameters` props. These now take an array of `ParameterDescriptor`s or `TypeParameterDescriptor`s respectively. They can also simply take an array of strings.
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index 8c3800c..b508968 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -232,8 +232,7 @@ export interface OutputScope {
 export type CreateSymbolOptions<T extends OutputSymbol = OutputSymbol> = {
   name: string;
   scope?: OutputScope;
-  refkey?: Refkey;
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
   flags?: OutputSymbolFlags;
   metadata?: Record<string, unknown>;
 } & Omit<T, keyof OutputSymbol>;
@@ -530,20 +529,12 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       name,
       scope = useDefaultScope(args.flags),
       refkey,
-      refkeys,
       flags = OutputSymbolFlags.None,
       metadata = {},
       ...rest
     } = args;
 
-    const allRefkeys = [];
-    if (refkey) {
-      allRefkeys.push(refkey);
-    }
-
-    if (refkeys) {
-      allRefkeys.push(...refkeys);
-    }
+    const allRefkeys = [refkey ?? []].flat();
 
     if (!scope) {
       throw new Error(
@@ -641,8 +632,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
         createSymbol({
           name: sym.name,
           scope: target.instanceMemberScope!,
-          // todo: fix this or change this or something??
-          refkeys: [refkey(target.refkeys[0], sym.refkeys[0])],
+          refkey: [refkey(target.refkeys[0], sym.refkeys[0])],
           flags: sym.flags | OutputSymbolFlags.InstanceMember,
         });
       }
diff --git a/packages/core/src/components/Declaration.tsx b/packages/core/src/components/Declaration.tsx
index 84f16f2..23ffc67 100644
--- a/packages/core/src/components/Declaration.tsx
+++ b/packages/core/src/components/Declaration.tsx
@@ -29,14 +29,9 @@ export interface DeclarationPropsWithInfo {
   name: string;
 
   /**
-   * The unique key for this declaration.
+   * The unique key or array of unique keys for this declaration.
    */
-  refkey?: Refkey;
-
-  /**
-   * Multiple unique keys for this declaration.
-   */
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 
   /**
    * Additional metadata for the declared symbol.
@@ -80,7 +75,6 @@ export function Declaration(props: DeclarationProps) {
     declaration = binder.createSymbol({
       name: props.name!,
       refkey: props.refkey,
-      refkeys: props.refkeys,
       metadata: props.metadata,
     });
 
diff --git a/packages/core/src/components/MemberDeclaration.tsx b/packages/core/src/components/MemberDeclaration.tsx
index fdf6012..56af357 100644
--- a/packages/core/src/components/MemberDeclaration.tsx
+++ b/packages/core/src/components/MemberDeclaration.tsx
@@ -16,14 +16,9 @@ export interface MemberDeclarationPropsWithInfo {
   name: string;
 
   /**
-   * The unique key for this declaration.
+   * The refkey or array refkeys for this declaration.
    */
-  refkey?: Refkey;
-
-  /**
-   * Multiple unique keys for this declaration.
-   */
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 
   /**
    * Additional metadata for the declared symbol.
@@ -93,7 +88,6 @@ export function MemberDeclaration(props: MemberDeclarationProps) {
     declaration = binder.createSymbol({
       name: infoProps.name!,
       refkey: infoProps.refkey,
-      refkeys: infoProps.refkeys,
       metadata: infoProps.metadata,
       flags:
         infoProps.static ?
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index 149b693..2bad674 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -1,6 +1,7 @@
 // Much of the implementations in this file are inspired by vuerx-js
 // See: https://github.com/ryansolid/vuerx-jsx.
 import {
+  computed,
   isReactive,
   pauseTracking,
   proxyRefs,
@@ -622,6 +623,59 @@ export function splitProps<
   return [...result, remaining] as any;
 }
 
+/**
+ * Applies default values to a props object. Reactive props are handled properly
+ * by ensuring that their value is not accessed by `defaultProps`, avoiding any
+ * unintended side effects.
+ */
+export function defaultProps<T extends {}>(props: T, defaults: Partial<T>): T {
+  if (isReactive(props)) {
+    const refs = untrack(() => toRefs(props));
+    for (const key in defaults) {
+      const originalRef = refs[key];
+      refs[key] = computed(() =>
+        originalRef.value === undefined ? defaults[key] : originalRef.value,
+      ) as any;
+    }
+
+    return proxyRefs(refs);
+  }
+  const withDefaults = {} as T;
+  const copied = new Set<string>();
+  for (const key in defaults) {
+    copied.add(key);
+    const desc = Object.getOwnPropertyDescriptor(props, key);
+    if (!desc) {
+      withDefaults[key] = defaults[key]!;
+      continue;
+    }
+
+    if (desc.get) {
+      const originalGet = desc.get;
+      desc.get = function () {
+        const value = originalGet.call(this);
+        return value === undefined ? defaults[key as keyof T] : value;
+      };
+      Object.defineProperty(withDefaults, key, desc);
+    } else {
+      desc.value =
+        desc.value === undefined ? defaults[key as keyof T] : desc.value;
+      Object.defineProperty(withDefaults, key, desc);
+    }
+  }
+
+  const descriptors = Object.getOwnPropertyDescriptors(props);
+  for (const key in descriptors) {
+    if (copied.has(key)) {
+      continue;
+    }
+
+    Object.defineProperty(withDefaults, key, descriptors[key]);
+  }
+
+  return withDefaults;
+}
+
 function shouldDebug() {
   return typeof process !== "undefined" && !!process.env?.ALLOY_DEBUG;
 }
diff --git a/packages/json/src/components/JsonArray.tsx b/packages/json/src/components/JsonArray.tsx
index 0c85493..8877321 100644
--- a/packages/json/src/components/JsonArray.tsx
+++ b/packages/json/src/components/JsonArray.tsx
@@ -15,16 +15,11 @@ import { createJsonOutputSymbol } from "../symbols/json-symbol.js";
 import { JsonValue } from "./JsonValue.jsx";
 
 export interface JsonArrayPropsBase {
-  /** The refkey for the JSON array. When provided, this value can be referenced
-   * elsewhere via this refkey.
-   **/
-  refkey?: Refkey;
-
   /**
-   * The refkeys for the JSON array. When provided, this array can be referenced
-   * elsewhere via any of these refkeys.
+   * The refkey or array of refkeys for the JSON array. When provided, this
+   * value can be referenced elsewhere via this refkey.
    **/
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 }
 
 /**
@@ -67,10 +62,7 @@ export function JsonArray(props: JsonArrayProps) {
   const binder = memberSymbol.binder;
   binder.addStaticMembersToSymbol(memberSymbol);
   if (props.refkey) {
-    memberSymbol.refkeys = [
-      ...(props.refkey ? [props.refkey] : []),
-      ...(props.refkeys ?? []),
-    ];
+    memberSymbol.refkeys = [props.refkey].flat();
   }
 
   if (!("jsValue" in props)) {
diff --git a/packages/json/src/components/JsonObject.tsx b/packages/json/src/components/JsonObject.tsx
index e2a9af9..91288df 100644
--- a/packages/json/src/components/JsonObject.tsx
+++ b/packages/json/src/components/JsonObject.tsx
@@ -15,16 +15,11 @@ import { createJsonOutputSymbol } from "../symbols/json-symbol.js";
 import { JsonValue } from "./JsonValue.jsx";
 
 export interface JsonObjectPropsBase {
-  /** The refkey for the JSON object. When provided, this value can be referenced
-   * elsewhere via this refkey.
-   **/
-  refkey?: Refkey;
-
   /**
-   * The refkeys for the JSON object. When provided, this value can be
-   * referenced elsewhere via any of these refkeys.
+   * The refkey or array of refkeys for the JSON object. When provided, this
+   * value can be referenced elsewhere via this refkey.
    **/
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 
   style?: { concise?: boolean };
 }
@@ -72,10 +67,7 @@ export function JsonObject(props: JsonObjectProps) {
   const binder = memberSymbol.binder;
   binder.addStaticMembersToSymbol(memberSymbol);
   if (props.refkey) {
-    memberSymbol.refkeys = [
-      ...(props.refkey ? [props.refkey] : []),
-      ...(props.refkeys ?? []),
-    ];
+    memberSymbol.refkeys = [props.refkey].flat();
   }
 
   if (!("jsValue" in props)) {
diff --git a/packages/json/src/components/JsonValue.tsx b/packages/json/src/components/JsonValue.tsx
index 08a2fa1..6fc1caf 100644
--- a/packages/json/src/components/JsonValue.tsx
+++ b/packages/json/src/components/JsonValue.tsx
@@ -4,16 +4,10 @@ import { JsonObject } from "./JsonObject.jsx";
 
 export interface JsonValueProps {
   /**
-   * The refkey for the JSON value. When provided, this value can be referenced
-   * elsewhere via this refkey.
+   * The refkey or array of refkeys for the JSON value. When provided, this
+   * value can be referenced elsewhere via this refkey.
    **/
-  refkey?: Refkey;
-
-  /**
-   * The refkeys for the JSON value. When provided, these values can be
-   * referenced elsewhere via any of these refkeys.
-   **/
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 
   /**
    * The JS value to serialize to JSON. When provided, thi
@@ -29,10 +23,7 @@ export interface JsonValueProps {
  * appropriate.
  */
 export function JsonValue(props: JsonValueProps) {
-  const refkeys = [
-    ...(props.refkey ? [props.refkey] : []),
-    ...(props.refkeys ?? []),
-  ];
+  const refkeys = [props.refkey ?? []].flat();
   if (props.jsValue === null) {
     setMemberRefkey(refkeys);
     return "null";
diff --git a/packages/typescript/src/components/CallSignature.tsx b/packages/typescript/src/components/CallSignature.tsx
new file mode 100644
index 0000000..9f1a335
--- /dev/null
+++ b/packages/typescript/src/components/CallSignature.tsx
@@ -0,0 +1,67 @@
+import { Children } from "@alloy-js/core/jsx-runtime";
+import {
+  FunctionDeclaration,
+  ParameterDescriptor,
+  TypeParameterDescriptor,
+} from "./FunctionDeclaration.jsx";
+
+export interface CallSignatureProps {
+  /**
+   * The parameters to the function. Can be an array of strings for parameters
+   * which don't have a type or a default value. Otherwise, it's an array of
+   * {@link ParameterDescriptor}s.
+   */
+  parameters?: ParameterDescriptor[] | string[];
+
+  /**
+   * Raw content to be used as the parameter list.
+   */
+  parametersChildren?: Children;
+
+  /**
+   * The type parameters for the function. Can be an array of strings for
+   * type parameters which don't have any constraints or a default type, otherwise
+   * it's an array of {@link TypeParameterDescriptor}s.
+   */
+  typeParameters?: TypeParameterDescriptor[] | string[];
+
+  /**
+   * Raw content to be used as the type parameter list.
+   */
+  typeParametersChildren?: Children;
+
+  /**
+   * The return type of the function.
+   */
+  returnType?: Children;
+}
+/**
+ * A TypeScript call signature, e.g. the part after the `function` keyword in an
+ * anonymous function expression, or a call signature in an interface.
+ *
+ * Any parameters or type parameters declared in this signature will be placed
+ * in the current scope. This component does not make a scope to hold its
+ * parameters.
+ */
+export function CallSignature(props: CallSignatureProps) {
+  const sTypeParameters =
+    props.typeParametersChildren ?
+      <>
+        {"<"}
+        {props.typeParametersChildren}
+        {">"}
+      </>
+    : <FunctionDeclaration.TypeParameters parameters={props.typeParameters} />;
+
+  const sParams = props.parametersChildren ?? (
+    <FunctionDeclaration.Parameters parameters={props.parameters} />
+  );
+
+  const sReturnType = props.returnType ? <>: {props.returnType}</> : undefined;
+
+  return (
+    <>
+      {sTypeParameters}({sParams}){sReturnType}
+    </>
+  );
+}
diff --git a/packages/typescript/src/components/ClassDeclaration.tsx b/packages/typescript/src/components/ClassDeclaration.tsx
index e17a9b9..f035772 100644
--- a/packages/typescript/src/components/ClassDeclaration.tsx
+++ b/packages/typescript/src/components/ClassDeclaration.tsx
@@ -10,11 +10,9 @@ import {
 } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
 import { createTSSymbol, TSOutputSymbol } from "../symbols/ts-output-symbol.js";
+import { getCallSignatureProps } from "../utils.js";
+import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import { BaseDeclarationProps, Declaration } from "./Declaration.jsx";
-import {
-  FunctionDeclaration,
-  ParameterDescriptor,
-} from "./FunctionDeclaration.jsx";
 
 export interface ClassDeclarationProps extends BaseDeclarationProps {
   extends?: Children;
@@ -128,22 +126,20 @@ export function ClassField(props: ClassFieldProps) {
   );
 }
 
-export interface ClassMethodProps extends ClassMemberProps {
+export interface ClassMethodProps extends ClassMemberProps, CallSignatureProps {
   async?: boolean;
-  parameters?: Record<string, Children> | ParameterDescriptor[];
-  returnType?: Children;
   children?: Children;
 }
 
 export function ClassMethod(props: ClassMethodProps) {
-  const returnType = props.returnType && <>: {props.returnType}</>;
+  const callProps = getCallSignatureProps(props);
+
   return (
     <ClassMember {...props}>
       {props.async && "async "}
       <MemberName />
       <Scope name={props.name} kind="function">
-        (<FunctionDeclaration.Parameters parameters={props.parameters} />)
-        {returnType} <Block>{props.children}</Block>
+        <CallSignature {...callProps} /> <Block>{props.children}</Block>
       </Scope>
     </ClassMember>
   );
diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx
index c2c578f..312a774 100644
--- a/packages/typescript/src/components/Declaration.tsx
+++ b/packages/typescript/src/components/Declaration.tsx
@@ -24,14 +24,9 @@ export interface BaseDeclarationProps {
   name: string;
 
   /**
-   * The unique key for this declaration.
+   * The refkey or array of refkeys for this declaration.
    */
-  refkey?: Refkey;
-
-  /**
-   * Multiple unique keys for this declaration.
-   */
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 
   /**
    * Whether to export this declaration from the module.
@@ -94,7 +89,6 @@ export function Declaration(props: DeclarationProps) {
   const sym = createTSSymbol({
     name: namePolicy.getName(props.name, props.nameKind),
     refkey: props.refkey,
-    refkeys: props.refkeys,
     export: props.export,
     default: props.default,
     flags: props.flags,
diff --git a/packages/typescript/src/components/EnumDeclaration.tsx b/packages/typescript/src/components/EnumDeclaration.tsx
index 258c9a5..49974b4 100644
--- a/packages/typescript/src/components/EnumDeclaration.tsx
+++ b/packages/typescript/src/components/EnumDeclaration.tsx
@@ -31,7 +31,6 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
     scope,
     name: name,
     refkey: props.refkey,
-    refkeys: props.refkeys,
     default: props.default,
     export: props.export,
     flags: OutputSymbolFlags.StaticMemberContainer,
diff --git a/packages/typescript/src/components/FunctionDeclaration.tsx b/packages/typescript/src/components/FunctionDeclaration.tsx
index 2db42c2..df2a379 100644
--- a/packages/typescript/src/components/FunctionDeclaration.tsx
+++ b/packages/typescript/src/components/FunctionDeclaration.tsx
@@ -8,7 +8,6 @@ import {
   Indent,
   Name,
   Refkey,
-  refkey,
   Scope,
   Show,
   taggedComponent,
@@ -20,6 +19,8 @@ import {
   TSOutputSymbol,
   TSSymbolFlags,
 } from "../symbols/index.js";
+import { getCallSignatureProps } from "../utils.js";
+import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
 
 /**
@@ -37,14 +38,14 @@ export interface ParameterDescriptor {
   type?: Children;
 
   /**
-   * The refkey for this parameter.
+   * The default value of the parameter.
    */
-  refkey?: Refkey;
+  default?: Children;
 
   /**
-   * The refkeys for this parameter.
+   * The refkey for this parameter.
    */
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
 
   /**
    * Whether the parameter is optional.
@@ -57,11 +58,40 @@ export interface ParameterDescriptor {
   metadata?: Record<string, unknown>;
 }
 
-export interface FunctionDeclarationProps extends BaseDeclarationProps {
+/**
+ * Information for a TypeScript generic type parameter.
+ */
+export interface TypeParameterDescriptor {
+  /**
+   * The name of the type parameter.
+   */
+  name: string;
+
+  /**
+   * The extends constraint for the type parameter.
+   */
+  extends?: Children;
+
+  /**
+   * The default type of the type parameter.
+   */
+  default?: Children;
+
+  /**
+   * A refkey or array of refkeys for this type parameter.
+   */
+  refkey?: Refkey | Refkey[];
+
+  /**
+   * Arbitrary metadata for the type parameter symbol.
+   */
+  metadata?: Record<string, unknown>;
+}
+
+export interface FunctionDeclarationProps
+  extends BaseDeclarationProps,
+    CallSignatureProps {
   async?: boolean;
-  parameters?: Record<string, Children> | ParameterDescriptor[];
-  typeParameters?: Record<string, Children> | ParameterDescriptor[] | string[];
-  returnType?: Children;
   children?: Children;
 }
 
@@ -69,42 +99,49 @@ export const functionParametersTag = Symbol();
 export const functionTypeParametersTag = Symbol();
 export const functionBodyTag = Symbol();
 
+/**
+ * A TypeScript function declaration.
+ *
+ * @remarks
+ *
+ * Providing parameters and type parameters can be accomplished in one of three
+ * ways:
+ *
+ * 1. As an array of {@link ParameterDescriptor}s or
+ *    {@link TypeParameterDescriptor}s.
+ * 2. As raw content via the `parametersChildren` or `typeParametersChildren`
+ *    props.
+ * 3. As a child of this component via the
+ *    {@link (FunctionDeclaration:namespace).Parameters} or
+ *    {@link (FunctionDeclaration:namespace).TypeParameters} components.
+ */
 export function FunctionDeclaration(props: FunctionDeclarationProps) {
   const children = childrenArray(() => props.children);
-  const typeParametersChild = findKeyedChild(
-    children,
-    functionTypeParametersTag,
-  );
-  const parametersChild = findKeyedChild(children, functionParametersTag);
-  const bodyChild = findKeyedChild(children, functionBodyTag);
+  const typeParametersChildren =
+    findKeyedChild(children, functionTypeParametersTag) ?? undefined;
+  const parametersChildren =
+    findKeyedChild(children, functionParametersTag) ?? undefined;
+  const bodyChildren = findKeyedChild(children, functionBodyTag) ?? undefined;
   const filteredChildren = findUnkeyedChildren(children);
   const returnType = getReturnType(props.returnType, { async: props.async });
-  const sReturnType = returnType ? <>: {returnType}</> : undefined;
-
-  const sTypeParameters =
-    typeParametersChild ?
-      <>
-        {"<"}
-        {typeParametersChild}
-        {">"}
-      </>
-    : <FunctionDeclaration.TypeParameters parameters={props.typeParameters} />;
 
-  const sParams = parametersChild ?? (
-    <FunctionDeclaration.Parameters parameters={props.parameters} />
-  );
-
-  const sBody = bodyChild ?? (
+  const sBody = bodyChildren ?? (
     <FunctionDeclaration.Body>{filteredChildren}</FunctionDeclaration.Body>
   );
 
   const asyncKwd = props.async ? "async " : "";
 
+  const callSignatureProps = getCallSignatureProps(props, {
+    parametersChildren,
+    typeParametersChildren,
+  });
+
   return (
     <Declaration {...props} nameKind="function">
       {asyncKwd}function <Name />
       <Scope name={props.name} kind="function">
-        {sTypeParameters}({sParams}){sReturnType} {sBody}
+        <CallSignature {...callSignatureProps} returnType={returnType} />{" "}
+        {sBody}
       </Scope>
     </Declaration>
   );
@@ -124,68 +161,64 @@ interface DeclaredParameterDescriptor
   symbol: TSOutputSymbol;
 }
 
+interface DeclaredTypeParameterDescriptor
+  extends Omit<TypeParameterDescriptor, "name"> {
+  symbol: TSOutputSymbol;
+}
+function normalizeAndDeclareParameters(
+  parameters: TypeParameterDescriptor[] | string[],
+  flags?: TSSymbolFlags,
+): DeclaredTypeParameterDescriptor[];
+function normalizeAndDeclareParameters(
+  parameters: ParameterDescriptor[] | string[],
+  flags?: TSSymbolFlags,
+): DeclaredParameterDescriptor[];
+function normalizeAndDeclareParameters(
+  parameters: string[],
+  flags?: TSSymbolFlags,
+): DeclaredParameterDescriptor[] | DeclaredTypeParameterDescriptor[];
 function normalizeAndDeclareParameters(
-  parameters: Record<string, Children> | ParameterDescriptor[] | string[],
+  parameters: ParameterDescriptor[] | TypeParameterDescriptor[] | string[],
   flags: TSSymbolFlags = TSSymbolFlags.ParameterSymbol,
-): DeclaredParameterDescriptor[] {
+): DeclaredParameterDescriptor[] | DeclaredTypeParameterDescriptor[] {
   const namePolicy = useTSNamePolicy();
-  if (Array.isArray(parameters)) {
-    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,
-        };
+  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",
+        ),
+        tsFlags: flags,
       });
-    }
-  } else {
-    return Object.entries(parameters).map(([paramName, value]) => {
-      const descriptor: DeclaredParameterDescriptor = {
-        type: value,
-        symbol: undefined as any,
-      };
 
-      descriptor.symbol = createTSSymbol({
+      return { refkeys: symbol.refkeys, symbol };
+    });
+  } else {
+    return (parameters as ParameterDescriptor[]).map((param) => {
+      const symbol = createTSSymbol({
         name: namePolicy.getName(
-          paramName,
+          param.name,
           flags & TSSymbolFlags.TypeSymbol ? "type" : "parameter",
         ),
+        refkey: param.refkey,
         tsFlags: flags,
+        metadata: param.metadata,
       });
 
-      return descriptor;
+      return {
+        ...param,
+        symbol,
+      };
     });
   }
 }
 
 export interface FunctionTypeParametersProps {
-  parameters?: Record<string, Children> | ParameterDescriptor[] | string[];
+  parameters?: ParameterDescriptor[] | string[];
   children?: Children;
 }
 
@@ -225,21 +258,21 @@ FunctionDeclaration.TypeParameters = taggedComponent(
   },
 );
 
-function typeParameter(param: DeclaredParameterDescriptor) {
+function typeParameter(param: DeclaredTypeParameterDescriptor) {
   return (
     <group>
       {param.symbol.name}
-      <Show when={!!param.type}>
+      <Show when={!!param.extends}>
         {" "}
         extends
-        <indent> {param.type}</indent>
+        <indent> {param.extends}</indent>
       </Show>
     </group>
   );
 }
 
 export interface FunctionParametersProps {
-  parameters?: Record<string, Children> | ParameterDescriptor[];
+  parameters?: ParameterDescriptor[] | string[];
   children?: Children;
 }
 
diff --git a/packages/typescript/src/components/Interface.tsx b/packages/typescript/src/components/Interface.tsx
index d45e15c..c713f1d 100644
--- a/packages/typescript/src/components/Interface.tsx
+++ b/packages/typescript/src/components/Interface.tsx
@@ -44,7 +44,7 @@ export interface InterfaceMemberProps {
 }
 
 /**
- * Create a TypeScript interface declaration.
+ * Create a TypeScript interface member.
  */
 export function InterfaceMember(props: InterfaceMemberProps) {
   const namer = useTSNamePolicy();
diff --git a/packages/typescript/src/components/VarDeclaration.tsx b/packages/typescript/src/components/VarDeclaration.tsx
index c00bd2e..fe4bd97 100644
--- a/packages/typescript/src/components/VarDeclaration.tsx
+++ b/packages/typescript/src/components/VarDeclaration.tsx
@@ -27,7 +27,6 @@ export function VarDeclaration(props: VarDeclarationProps) {
   const sym = createTSSymbol({
     name: name,
     refkey: props.refkey,
-    refkeys: props.refkeys,
     default: props.default,
     export: props.export,
     metadata: props.metadata,
diff --git a/packages/typescript/src/components/index.ts b/packages/typescript/src/components/index.ts
index f2c17af..4446427 100644
--- a/packages/typescript/src/components/index.ts
+++ b/packages/typescript/src/components/index.ts
@@ -1,6 +1,7 @@
 export * from "./ArrayExpression.js";
 export * from "./BarrelFile.js";
 export * from "./BlockScope.js";
+export * from "./CallSignature.jsx";
 export * from "./ClassDeclaration.jsx";
 export * from "./CommaList.jsx";
 export * from "./Declaration.js";
diff --git a/packages/typescript/src/components/stc/index.ts b/packages/typescript/src/components/stc/index.ts
index 05e9e4b..69b1aed 100644
--- a/packages/typescript/src/components/stc/index.ts
+++ b/packages/typescript/src/components/stc/index.ts
@@ -3,6 +3,7 @@ import * as base from "../index.js";
 
 export const ArrayExpression = stc(base.ArrayExpression);
 export const BarrelFile = stc(base.BarrelFile);
+export const CallSignature = stc(base.CallSignature);
 export const ClassDeclaration = stc(base.ClassDeclaration);
 export const ClassField = stc(base.ClassField);
 export const ClassMember = stc(base.ClassMember);
diff --git a/packages/typescript/src/symbols/ts-output-symbol.ts b/packages/typescript/src/symbols/ts-output-symbol.ts
index 4b3d105..3ed205c 100644
--- a/packages/typescript/src/symbols/ts-output-symbol.ts
+++ b/packages/typescript/src/symbols/ts-output-symbol.ts
@@ -26,8 +26,7 @@ export interface TSOutputSymbol extends OutputSymbol {
 
 export interface createTsSymbolOptions {
   name: string;
-  refkey?: Refkey;
-  refkeys?: Refkey[];
+  refkey?: Refkey | Refkey[];
   binder?: Binder;
   scope?: TSOutputScope;
   export?: boolean;
@@ -51,7 +50,6 @@ export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
     name: options.name,
     scope,
     refkey: options.refkey,
-    refkeys: options.refkeys,
     export: !!options.export,
     default: !!options.default,
     flags: options.flags ?? OutputSymbolFlags.None,
diff --git a/packages/typescript/src/utils.ts b/packages/typescript/src/utils.ts
index 67386fe..5831acf 100644
--- a/packages/typescript/src/utils.ts
+++ b/packages/typescript/src/utils.ts
@@ -1,3 +1,6 @@
+import { defaultProps, splitProps } from "@alloy-js/core/jsx-runtime";
+import { CallSignatureProps } from "./components/CallSignature.jsx";
+
 export function modulePath(path: string) {
   if (path[0] !== ".") {
     path = "./" + path;
@@ -5,3 +8,26 @@ export function modulePath(path: string) {
 
   return path.replace(/\.ts$/, ".js");
 }
+
+/**
+ * Extract only the call signature props from a props object which extends
+ * `CallSignatureProps`. You can provide default values for the props.
+ */
+export function getCallSignatureProps(
+  props: CallSignatureProps,
+  defaults?: Partial<CallSignatureProps>,
+) {
+  const [callSignatureProps] = splitProps(props, [
+    "parameters",
+    "parametersChildren",
+    "typeParameters",
+    "typeParametersChildren",
+    "returnType",
+  ]);
+
+  if (!defaults) {
+    return callSignatureProps;
+  }
+
+  return defaultProps(callSignatureProps, defaults);
+}
diff --git a/samples/basic-project/src/index.tsx b/samples/basic-project/src/index.tsx
index c7b5d53..c55d2b3 100644
--- a/samples/basic-project/src/index.tsx
+++ b/samples/basic-project/src/index.tsx
@@ -13,10 +13,10 @@ const result = ay.render(
       <ts.SourceFile path="greetings.ts">
         <ts.FunctionDeclaration
           name="getGreeting"
-          parameters={{
-            foo: "string",
-            bar: "string",
-          }}
+          parameters={[
+            { name: "foo", type: "string" },
+            { name: "bar", type: "string" },
+          ]}
         >
           <ts.VarDeclaration name="foo">"string"</ts.VarDeclaration>
           return "Hello world!";


Test patch:
diff --git a/packages/core/test/props-with-defaults.test.ts b/packages/core/test/props-with-defaults.test.ts
new file mode 100644
index 0000000..a1ae150
--- /dev/null
+++ b/packages/core/test/props-with-defaults.test.ts
@@ -0,0 +1,97 @@
+import { effect, reactive } from "@vue/reactivity";
+import { expect, it, vi } from "vitest";
+import { defaultProps } from "../src/jsx-runtime.js";
+
+it("applies defaults to regular object props", () => {
+  const props = {
+    a: 1,
+    b: 2,
+    c: undefined as number | undefined,
+  };
+
+  const dProps = defaultProps(props, { a: 10, c: 20 });
+
+  expect(dProps).toEqual({ a: 1, b: 2, c: 20 });
+});
+
+it("applies defaults with getters without invoking them", () => {
+  const getterA = vi.fn(() => 1);
+  const getterB = vi.fn(() => undefined as number | undefined);
+  const props = {
+    get a() {
+      return getterA();
+    },
+    get b() {
+      return getterB();
+    },
+    c: 3,
+  };
+
+  const dProps = defaultProps(props, { b: 20, c: 30 });
+
+  expect(getterA).not.toHaveBeenCalled();
+  expect(getterB).not.toHaveBeenCalled();
+
+  const value = dProps.b;
+  expect(getterB).toHaveBeenCalledTimes(1);
+  expect(value).toEqual(20);
+});
+
+it("applies defaults to reactives without observing them initially", () => {
+  const props = reactive({
+    a: 1,
+    b: 2,
+    c: undefined as number | undefined,
+  });
+
+  const defaults = defaultProps(props, { c: 10 });
+
+  expect(defaults.a).toBe(1);
+  expect(defaults.b).toBe(2);
+  expect(defaults.c).toBe(10);
+});
+
+it("ensures effect is not triggered by defaults but by accessing reactive props", () => {
+  const props = reactive({
+    a: 1,
+    b: 2,
+    c: undefined as number | undefined,
+  });
+
+  let withDefaults: any;
+  const splitEffect = vi.fn(() => {
+    withDefaults = defaultProps(props, { c: 10 });
+  });
+
+  effect(splitEffect);
+  expect(splitEffect).toHaveBeenCalledTimes(1);
+
+  const observeEffect = vi.fn(() => {
+    // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+    withDefaults.a;
+  });
+  effect(observeEffect);
+  expect(observeEffect).toHaveBeenCalledTimes(1);
+
+  props.a = 2;
+  expect(splitEffect).toHaveBeenCalledTimes(1);
+  expect(observeEffect).toHaveBeenCalledTimes(2);
+});
+
+it("applies defaults to reactives", () => {
+  const props = reactive({
+    a: 1,
+    b: 2,
+    c: undefined as number | undefined,
+  });
+
+  const withDefaults = defaultProps(props, { c: 10 });
+
+  expect(withDefaults.c).toBe(10);
+  props.c = 20;
+
+  expect(withDefaults.c).toBe(20);
+  props.c = undefined;
+
+  expect(withDefaults.c).toBe(10);
+});
diff --git a/packages/typescript/test/basic.test.tsx b/packages/typescript/test/basic.test.tsx
index 3a945c2..c0505d9 100644
--- a/packages/typescript/test/basic.test.tsx
+++ b/packages/typescript/test/basic.test.tsx
@@ -40,7 +40,7 @@ it("works", () => {
           <ts.FunctionDeclaration
             name={"say" + fnSpec.greeting}
             refkey={greetKey}
-            parameters={{ str: "string" }}
+            parameters={[{ name: "str", type: "string" }]}
           >
             return "{fnSpec.greeting} " + str;
           </ts.FunctionDeclaration>
@@ -48,7 +48,7 @@ it("works", () => {
           <ts.FunctionDeclaration
             name={"say" + fnSpec.farewell}
             refkey={farewellKey}
-            parameters={{ str: "string" }}
+            parameters={[{ name: "str", type: "string" }]}
           >
             return "{fnSpec.farewell} " + str;
           </ts.FunctionDeclaration>
diff --git a/packages/typescript/test/class.test.tsx b/packages/typescript/test/class.test.tsx
index bccf792..ec8febe 100644
--- a/packages/typescript/test/class.test.tsx
+++ b/packages/typescript/test/class.test.tsx
@@ -159,7 +159,10 @@ describe("instance methods", () => {
           <ts.ClassMethod name="four" protected />
           <ts.ClassMethod
             name="five"
-            parameters={{ a: "string", b: "string" }}
+            parameters={[
+              { name: "a", type: "string" },
+              { name: "b", type: "string" },
+            ]}
           />
           <ts.ClassMethod
             name="six"
diff --git a/packages/typescript/test/function-call-expression.test.tsx b/packages/typescript/test/function-call-expression.test.tsx
index 4609b86..fa6dc63 100644
--- a/packages/typescript/test/function-call-expression.test.tsx
+++ b/packages/typescript/test/function-call-expression.test.tsx
@@ -20,7 +20,10 @@ it("can declare and call a function with parameters", () => {
           <FunctionDeclaration
             refkey={functionRefkey}
             name="bar"
-            parameters={{ "foo?": "string", "baz?": "number" }}
+            parameters={[
+              { name: "foo", optional: true, type: "string" },
+              { name: "baz", optional: true, type: "number" },
+            ]}
           >
             <StatementList>
               <>const message = foo ? foo : "Hello, World!"</>
diff --git a/packages/typescript/test/function-declaration.test.tsx b/packages/typescript/test/function-declaration.test.tsx
index 2d82c08..67e2c16 100644
--- a/packages/typescript/test/function-declaration.test.tsx
+++ b/packages/typescript/test/function-declaration.test.tsx
@@ -76,7 +76,10 @@ it("supports type parameters by descriptor object", () => {
   const decl = (
     <FunctionDeclaration
       name="foo"
-      typeParameters={{ a: "any", b: "any" }}
+      typeParameters={[
+        { name: "a", extends: "any" },
+        { name: "b", extends: "any" },
+      ]}
     ></FunctionDeclaration>
   );
 
@@ -183,7 +186,10 @@ describe("symbols", () => {
   it("creates symbols for parameters and addresses conflicts", () => {
     const decl = (
       <>
-        <FunctionDeclaration name="foo" parameters={{ conflict: "any" }}>
+        <FunctionDeclaration
+          name="foo"
+          parameters={[{ name: "conflict", type: "any" }]}
+        >
           <VarDeclaration name="conflict">1</VarDeclaration>;
         </FunctionDeclaration>
       </>
diff --git a/packages/typescript/test/name-policy.test.tsx b/packages/typescript/test/name-policy.test.tsx
index dfa3568..8a17d22 100644
--- a/packages/typescript/test/name-policy.test.tsx
+++ b/packages/typescript/test/name-policy.test.tsx
@@ -74,7 +74,7 @@ it("appends _ to reserved words", () => {
         <ts.FunctionDeclaration
           name="default"
           refkey={ref1}
-          parameters={{ await: "any" }}
+          parameters={[{ name: "await", type: "any" }]}
         />
         <hbr />
         <StatementList>