Repository: alloy-framework/alloy (ts)

Issue:
Interface members with refkeys cause issues in InterfaceExpression
InterfaceExpression doesn't create a member scope. Perhaps a similar pattern to object expressions should be used - object expressions will check if there is an assignment symbol and if so, wrap its children in a MemberScope with an owner of that assignment symbol. This would mean, given:

```ts
interface Foo {
  outer: {
    inner: string;
  }
}
```

that the outer symbol would become a `SymbolFlags.StaticMemberContainer` and the symbol for `inner` would be a static member of the `outer` symbol.
Type reference to interface members seems broken
```ts
 <>
        <ts.InterfaceDeclaration name="Foo">
              <ts.InterfaceMember name="prop" type="string" refkey={ref} />
        </ts.InterfaceDeclaration>
        <line />
        <ts.InterfaceDeclaration name="Bar">
          <ts.InterfaceMember name="ref" type={<Reference refkey={ref} />} />
        </ts.InterfaceDeclaration>
      </>,
```

Produce `Foo.prop` but it should be `Foo["prop"]`

Pull request:
Fix interface member references
fix #108 
fix #111


Patch:
diff --git a/.chronus/changes/fix-interface-member-references-2025-3-14-18-48-27.md b/.chronus/changes/fix-interface-member-references-2025-3-14-18-48-27.md
new file mode 100644
index 0000000..8185216
--- /dev/null
+++ b/.chronus/changes/fix-interface-member-references-2025-3-14-18-48-27.md
@@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: fix
+packages:
+  - "@alloy-js/typescript"
+---
+
+Fix interface member references
diff --git a/.chronus/changes/fix-interface-member-references-2025-3-14-18-48-28.md b/.chronus/changes/fix-interface-member-references-2025-3-14-18-48-28.md
new file mode 100644
index 0000000..094ccd7
--- /dev/null
+++ b/.chronus/changes/fix-interface-member-references-2025-3-14-18-48-28.md
@@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Add new `<MemberDeclaration />` component. A wrapper around alloy `<MemberDeclaration />` with typespec specific props.
diff --git a/.chronus/changes/fix-interface-member-references-2025-3-15-14-56-16.md b/.chronus/changes/fix-interface-member-references-2025-3-15-14-56-16.md
new file mode 100644
index 0000000..b1f2db9
--- /dev/null
+++ b/.chronus/changes/fix-interface-member-references-2025-3-15-14-56-16.md
@@ -0,0 +1,6 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: internal
+packages:
+  - "@alloy-js/cli"
+---
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c247911..b95521e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,7 +35,7 @@ jobs:
         uses: actions/configure-pages@v5
       - name: Build with Astro
         run: |
-          pnpx astro build \
+          pnpm astro build \
             --site "${{ steps.pages.outputs.origin }}" \
             --base "${{ steps.pages.outputs.base_path }}"
         working-directory: ./packages/docs
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index 2aed24a..137fc28 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -78,8 +78,7 @@ function watchMain() {
 
   const origPostProgramCreate = host.afterProgramCreate;
   host.afterProgramCreate = async (program) => {
-    // eslint-disable-next-line no-console
-    console.clear();
+    ts.sys.clearScreen?.();
     try {
       await buildAllFiles(opts.fileNames, opts.rootDir, opts.outDir);
     } catch (e) {
diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx
index fdd8f46..5545e3b 100644
--- a/packages/typescript/src/components/Declaration.tsx
+++ b/packages/typescript/src/components/Declaration.tsx
@@ -109,7 +109,7 @@ export function Declaration(props: DeclarationProps) {
 
     let tsFlags: TSSymbolFlags = TSSymbolFlags.None;
     if (props.kind && props.kind === "type") {
-      tsFlags &= TSSymbolFlags.TypeSymbol;
+      tsFlags |= TSSymbolFlags.TypeSymbol;
     }
 
     sym = createTSSymbol({
diff --git a/packages/typescript/src/components/Interface.tsx b/packages/typescript/src/components/Interface.tsx
index 3e0f7f8..89aa6a1 100644
--- a/packages/typescript/src/components/Interface.tsx
+++ b/packages/typescript/src/components/Interface.tsx
@@ -1,16 +1,20 @@
 import {
   Block,
   Children,
-  MemberDeclaration,
+  MemberScope,
   Name,
   OutputSymbolFlags,
   Refkey,
   Show,
+  useMemberDeclaration,
+  useMemberScope,
+  Wrap,
 } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
 import { JSDoc } from "./JSDoc.jsx";
 
+import { MemberDeclaration } from "./MemberDeclaration.jsx";
 import { PropertyName } from "./PropertyName.jsx";
 
 export interface InterfaceDeclarationProps extends BaseDeclarationProps {
@@ -36,7 +40,7 @@ export function InterfaceDeclaration(props: InterfaceDeclarationProps) {
         <JSDoc children={props.doc} />
         <hbr />
       </Show>
-      <Declaration {...props} nameKind="interface" flags={flags}>
+      <Declaration {...props} nameKind="interface" flags={flags} kind="type">
         interface <Name />
         {extendsPart}{" "}
         <InterfaceExpression>{props.children}</InterfaceExpression>
@@ -50,7 +54,22 @@ export interface InterfaceExpressionProps {
 }
 
 export function InterfaceExpression(props: InterfaceExpressionProps) {
-  return <Block>{props.children}</Block>;
+  const parentMemberSym = useMemberDeclaration();
+
+  if (parentMemberSym) {
+    parentMemberSym.binder.addStaticMembersToSymbol(parentMemberSym);
+  }
+  return (
+    <group>
+      <Wrap
+        when={!!parentMemberSym}
+        with={MemberScope}
+        props={{ owner: parentMemberSym! }}
+      >
+        <Block>{props.children}</Block>
+      </Wrap>
+    </group>
+  );
 }
 
 export interface InterfaceMemberProps {
@@ -87,9 +106,18 @@ export function InterfaceMember(props: InterfaceMemberProps) {
   } else {
     const namer = useTSNamePolicy();
     const name = namer.getName(props.name!, "interface-member");
-    if (props.refkey) {
+    const memberScope = useMemberScope();
+
+    if (memberScope) {
       return (
-        <MemberDeclaration static name={name} refkey={props.refkey}>
+        <MemberDeclaration
+          static
+          exactName={name}
+          kind="type"
+          nameKind="interface-member"
+          flags={OutputSymbolFlags.StaticMember}
+          refkey={props.refkey}
+        >
           <Show when={Boolean(props.doc)}>
             <JSDoc children={props.doc} />
             <hbr />
diff --git a/packages/typescript/src/components/MemberDeclaration.tsx b/packages/typescript/src/components/MemberDeclaration.tsx
new file mode 100644
index 0000000..2b5961a
--- /dev/null
+++ b/packages/typescript/src/components/MemberDeclaration.tsx
@@ -0,0 +1,70 @@
+import {
+  MemberDeclaration as CoreMemberDeclaration,
+  MemberDeclarationPropsWithInfo,
+  OutputSymbolFlags,
+} from "@alloy-js/core";
+import { TypeScriptElements, useTSNamePolicy } from "../name-policy.js";
+import {
+  createTSSymbol,
+  TSOutputSymbol,
+  TSSymbolFlags,
+} from "../symbols/index.js";
+
+export interface MemberDeclarationProps
+  extends Omit<MemberDeclarationPropsWithInfo, "name"> {
+  /**
+   * The name of this member declaration.
+   */
+  name?: string;
+
+  /** Name that shouldn't go through the name policy resolver again.  */
+  exactName?: string;
+
+  /**
+   * Whether this is a memberdeclaration of a type (e.g. interface, type alias) or a
+   * value (e.g. var, const, let).
+   */
+  kind?: "type" | "value";
+
+  /**
+   * The name policy kind to apply to the memberdeclaration.
+   */
+  nameKind?: TypeScriptElements;
+
+  /**
+   * Flags for the symbol created by this component.
+   */
+  flags?: OutputSymbolFlags;
+
+  /**
+   * The symbol to use for this memberdeclaration.
+   */
+  symbol?: TSOutputSymbol;
+}
+
+export function MemberDeclaration(props: Readonly<MemberDeclarationProps>) {
+  let sym: TSOutputSymbol;
+
+  if (props.symbol) {
+    sym = props.symbol;
+  } else {
+    const namePolicy = useTSNamePolicy();
+
+    let tsFlags: TSSymbolFlags = TSSymbolFlags.None;
+    if (props.kind === "type") {
+      tsFlags |= TSSymbolFlags.TypeSymbol;
+    }
+
+    sym = createTSSymbol({
+      name: props.exactName ?? namePolicy.getName(props.name!, props.nameKind!),
+      refkey: props.refkey,
+      flags: props.flags,
+      tsFlags,
+      metadata: props.metadata,
+    });
+  }
+
+  return (
+    <CoreMemberDeclaration symbol={sym}>{props.children}</CoreMemberDeclaration>
+  );
+}
diff --git a/packages/typescript/src/symbols/reference.ts b/packages/typescript/src/symbols/reference.ts
index 0d6f7e9..a30a5e0 100644
--- a/packages/typescript/src/symbols/reference.ts
+++ b/packages/typescript/src/symbols/reference.ts
@@ -134,7 +134,10 @@ function buildMemberExpression(path: TSOutputSymbol[]) {
   for (const sym of path) {
     if (sym.tsFlags & TSSymbolFlags.PrivateMember) {
       memberExpr += `.#${sym.name}`;
-    } else if (isValidJSIdentifier(sym.name)) {
+    } else if (
+      isValidJSIdentifier(sym.name) &&
+      !(sym.tsFlags & TSSymbolFlags.TypeSymbol)
+    ) {
       memberExpr += `.${sym.name}`;
     } else {
       memberExpr += `[${JSON.stringify(sym.name)}]`;


Test patch:
diff --git a/packages/typescript/test/interface.test.tsx b/packages/typescript/test/interface.test.tsx
index 6b579c6..75fa527 100644
--- a/packages/typescript/test/interface.test.tsx
+++ b/packages/typescript/test/interface.test.tsx
@@ -109,18 +109,94 @@ it("can create readonly members", () => {
   `);
 });
 
-it("has interface expressions", () => {
+it("can reference member", () => {
+  const ref = refkey("Foo");
   const res = toSourceText(
-    <ts.InterfaceExpression>
-      <ts.InterfaceMember name="member" type="string" />;
-    </ts.InterfaceExpression>,
+    <>
+      <ts.InterfaceDeclaration name="Foo">
+        <ts.InterfaceMember name="prop" type="string" refkey={ref} />
+      </ts.InterfaceDeclaration>
+      <line />
+      <ts.InterfaceDeclaration name="Bar">
+        <ts.InterfaceMember name="ref" type={<Reference refkey={ref} />} />
+      </ts.InterfaceDeclaration>
+    </>,
   );
 
   expect(res).toEqual(d`
+  interface Foo {
+    prop: string
+  }
+  interface Bar {
+    ref: Foo["prop"]
+  }
+`);
+});
+
+describe("interface expressions", () => {
+  it("basic", () => {
+    const res = toSourceText(
+      <ts.InterfaceExpression>
+        <ts.InterfaceMember name="member" type="string" />;
+      </ts.InterfaceExpression>,
+    );
+
+    expect(res).toEqual(d`
     {
       member: string;
     }
   `);
+  });
+
+  it("nested", () => {
+    const res = toSourceText(
+      <ts.InterfaceExpression>
+        <ts.InterfaceMember name="outer">
+          <ts.InterfaceExpression>
+            <ts.InterfaceMember name="inner" type="string" />;
+          </ts.InterfaceExpression>
+        </ts.InterfaceMember>
+      </ts.InterfaceExpression>,
+    );
+
+    expect(res).toEqual(d`
+    {
+      outer: {
+        inner: string;
+      }
+    }
+  `);
+  });
+
+  it("can reference member when nested in a declaration", () => {
+    const ref = refkey("Foo");
+    const res = toSourceText(
+      <>
+        <ts.InterfaceDeclaration name="Foo">
+          <ts.InterfaceMember name="outer">
+            <ts.InterfaceExpression>
+              <ts.InterfaceMember name="inner" type="string" refkey={ref} />
+            </ts.InterfaceExpression>
+          </ts.InterfaceMember>
+        </ts.InterfaceDeclaration>
+        <line />
+        <ts.InterfaceDeclaration name="Bar">
+          <ts.InterfaceMember name="ref" type={<Reference refkey={ref} />} />
+        </ts.InterfaceDeclaration>
+      </>,
+    );
+
+    expect(res).toEqual(d`
+    interface Foo {
+      outer: {
+        inner: string
+      }
+    }
+    interface Bar {
+      ref: Foo["outer"]["inner"]
+    }
+  `);
+  });
 });
 
 it("supports the naming policy", () => {