Repository: alloy-framework/alloy
Pull request #76 (2025-04-04 16:53:39)

[TypeScript] Escape invalid names and handle private symbols properly.
* Remove some validation from core that doesn't allow necessary flexibility in language libraries.
* Escape invalid property name declarations via the PropertyName component.
* Properly construct member expressions involving invalid names (e.g. foo["invalid-name"]).

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/e18fe933-1a43-42f4-a810-f8a6de264c35)


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

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

</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@76
```

</p></details>
      
    

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

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

</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@76
```

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</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@76
```

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/40004952954"><code>c123026</code></a>_


Patch:
diff --git a/.chronus/changes/escape-names-2025-3-4-9-48-15.md b/.chronus/changes/escape-names-2025-3-4-9-48-15.md
new file mode 100644
index 0000000..01106b3
--- /dev/null
+++ b/.chronus/changes/escape-names-2025-3-4-9-48-15.md
@@ -0,0 +1,7 @@
+---
+changeKind: fix
+packages:
+  - "@alloy-js/core"
+---
+
+No longer validates that instance member symbols are referenced inside the instance member scope. Languages may allow refernecing instance member symbols in a variety of ways (e.g. TypeScript allows referencing private instance symbols in a private instance symbol scope).
\ No newline at end of file
diff --git a/.chronus/changes/escape-names-2025-3-4-9-49-17.md b/.chronus/changes/escape-names-2025-3-4-9-49-17.md
new file mode 100644
index 0000000..1e0ca48
--- /dev/null
+++ b/.chronus/changes/escape-names-2025-3-4-9-49-17.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Support declaring and referencing properties with invalid property names. Added a `PropertyName` component to handle escaping property declaration names.
\ No newline at end of file
diff --git a/.chronus/changes/escape-names-2025-3-4-9-50-22.md b/.chronus/changes/escape-names-2025-3-4-9-50-22.md
new file mode 100644
index 0000000..64d0323
--- /dev/null
+++ b/.chronus/changes/escape-names-2025-3-4-9-50-22.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Properly model private symbols. Private symbols are now stored in separate scopes and will no longer conflict with non-private symbols. Access rules for private symbols are verified (e.g. that static private symbols cannot be referenced outside their scope).
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index b508968..78bd741 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -706,12 +706,6 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     targetDeclarationBase: TSymbol,
   ): ResolutionResult<TScope, TSymbol> {
     if (targetDeclarationBase.flags & OutputSymbolFlags.InstanceMember) {
-      if (targetDeclarationBase.scope !== currentMemberScope) {
-        throw new Error(
-          "Cannot resolve member symbols from a different member scope",
-        );
-      }
-
       // todo: handle referencing nested objects by refkey
       return {
         pathUp: [],
diff --git a/packages/typescript/src/components/ClassDeclaration.tsx b/packages/typescript/src/components/ClassDeclaration.tsx
index ce67bdf..b4fbc06 100644
--- a/packages/typescript/src/components/ClassDeclaration.tsx
+++ b/packages/typescript/src/components/ClassDeclaration.tsx
@@ -2,21 +2,24 @@ import {
   Block,
   Children,
   MemberDeclaration,
-  MemberName,
   Name,
   OutputSymbolFlags,
   Refkey,
   Scope,
   Show,
   splitProps,
+  useMemberScope,
 } from "@alloy-js/core";
+import { usePrivateScope } from "../context/private-scope.js";
 import { useTSNamePolicy } from "../name-policy.js";
-import { createTSSymbol, TSOutputSymbol } from "../symbols/ts-output-symbol.js";
+import { TSOutputScope } from "../symbols/scopes.js";
+import { createTSSymbol, TSSymbolFlags } from "../symbols/ts-output-symbol.js";
 import { getCallSignatureProps } from "../utils.js";
 import { CallSignature, CallSignatureProps } from "./CallSignature.jsx";
 import { BaseDeclarationProps, Declaration } from "./Declaration.jsx";
 import { JSDoc } from "./JSDoc.jsx";
 import { JSDocParams } from "./JSDocParam.jsx";
+import { PropertyName } from "./PropertyName.jsx";
 import { Prose } from "./Prose.jsx";
 
 export interface ClassDeclarationProps extends BaseDeclarationProps {
@@ -57,8 +60,21 @@ export interface ClassDeclarationProps extends BaseDeclarationProps {
  * ```
  */
 export function ClassDeclaration(props: ClassDeclarationProps) {
+  const namePolicy = useTSNamePolicy();
   const extendsPart = props.extends && <> extends {props.extends}</>;
-  const flags = OutputSymbolFlags.MemberContainer;
+
+  const sym = createTSSymbol({
+    name: namePolicy.getName(props.name!, "class"),
+    refkey: props.refkey,
+    export: props.export,
+    default: props.default,
+    flags:
+      (props.flags ?? OutputSymbolFlags.None) |
+      (OutputSymbolFlags.MemberContainer |
+        OutputSymbolFlags.StaticMemberContainer),
+    tsFlags: TSSymbolFlags.PrivateMemberContainer,
+    metadata: props.metadata,
+  });
 
   return (
     <>
@@ -66,7 +82,7 @@ export function ClassDeclaration(props: ClassDeclarationProps) {
         <JSDoc children={props.doc} />
         <hbr />
       </Show>
-      <Declaration {...props} flags={flags} nameKind="class">
+      <Declaration symbol={sym} export={props.export} default={props.default}>
         class <Name />
         {extendsPart} <Block>{props.children}</Block>
       </Declaration>
@@ -87,35 +103,45 @@ export interface ClassMemberProps {
 }
 
 export function ClassMember(props: ClassMemberProps) {
-  let sym: TSOutputSymbol | undefined;
   const namer = useTSNamePolicy();
-  let name = namer.getName(props.name, "class-member-data");
-
-  if (props.refkey) {
-    let flags = OutputSymbolFlags.None;
-    if (props.static) {
-      flags |= OutputSymbolFlags.StaticMember;
-    } else {
-      flags |= OutputSymbolFlags.InstanceMember;
-    }
-
-    sym = createTSSymbol({
-      name: props.jsPrivate ? `#${name}` : name,
-      refkey: props.refkey,
-      flags,
-    });
-  } else if (props.jsPrivate) {
-    // we won't be using the symbol name, so munge the name
-    name = `#${name}`;
+  const name = namer.getName(props.name, "class-member-data");
+
+  let flags = OutputSymbolFlags.None;
+
+  if (props.static) {
+    flags |= OutputSymbolFlags.StaticMember;
+  } else {
+    flags |= OutputSymbolFlags.InstanceMember;
   }
 
+  let scope: TSOutputScope;
+  if (props.jsPrivate) {
+    const memberScope = usePrivateScope()!;
+    scope =
+      props.static ? memberScope.staticMembers : memberScope.instanceMembers;
+  } else {
+    const memberScope = useMemberScope();
+    scope = (
+      props.static ?
+        memberScope.staticMembers!
+      : memberScope.instanceMembers!) as TSOutputScope;
+  }
+
+  const sym = createTSSymbol({
+    name,
+    scope,
+    refkey: props.refkey,
+    flags,
+    tsFlags: props.jsPrivate ? TSSymbolFlags.PrivateMember : TSSymbolFlags.None,
+  });
+
   return (
     <>
       <Show when={Boolean(props.doc)}>
         <JSDoc children={props.doc} />
         <hbr />
       </Show>
-      <MemberDeclaration symbol={sym} name={name} refkey={props.refkey}>
+      <MemberDeclaration symbol={sym}>
         {props.public && "public "}
         {props.private && "private "}
         {props.protected && "protected "}
@@ -137,7 +163,7 @@ export function ClassField(props: ClassFieldProps) {
 
   return (
     <ClassMember {...props}>
-      <MemberName />
+      <PropertyName />
       {typeSection}
       {initializerSection}
     </ClassMember>
@@ -166,7 +192,7 @@ export function ClassMethod(props: ClassMethodProps) {
       </Show>
       <ClassMember {...rest}>
         {props.async && "async "}
-        <MemberName />
+        <PropertyName />
         <Scope name={props.name} kind="function">
           <CallSignature {...callProps} /> <Block>{props.children}</Block>
         </Scope>
diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx
index 8759e75..fdd8f46 100644
--- a/packages/typescript/src/components/Declaration.tsx
+++ b/packages/typescript/src/components/Declaration.tsx
@@ -6,11 +6,17 @@ import {
   Refkey,
 } from "@alloy-js/core";
 import { TypeScriptElements, useTSNamePolicy } from "../name-policy.js";
-import { createTSSymbol, TSSymbolFlags } from "../symbols/index.js";
+import {
+  createTSSymbol,
+  TSOutputSymbol,
+  TSSymbolFlags,
+} from "../symbols/index.js";
 
 // imports for documentation
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { TypeDeclaration } from "./TypeDeclaration.jsx";
+
+import { PrivateScopeContext } from "../context/private-scope.js";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { EnumDeclaration } from "./EnumDeclaration.jsx";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -62,11 +68,21 @@ export interface BaseDeclarationProps {
   doc?: Children;
 }
 
-export interface DeclarationProps extends BaseDeclarationProps {
+export interface DeclarationProps extends Omit<BaseDeclarationProps, "name"> {
+  /**
+   * The name of this declaration.
+   */
+  name?: string;
+
   /**
    * The name policy kind to apply to the declaration.
    */
-  nameKind: TypeScriptElements;
+  nameKind?: TypeScriptElements;
+
+  /**
+   * The symbol to use for this declaration.
+   */
+  symbol?: TSOutputSymbol;
 }
 
 /**
@@ -84,28 +100,53 @@ export interface DeclarationProps extends BaseDeclarationProps {
  *
  */
 export function Declaration(props: DeclarationProps) {
-  const namePolicy = useTSNamePolicy();
+  let sym: TSOutputSymbol;
 
-  let tsFlags: TSSymbolFlags = TSSymbolFlags.None;
-  if (props.kind && props.kind === "type") {
-    tsFlags &= TSSymbolFlags.TypeSymbol;
+  if (props.symbol) {
+    sym = props.symbol;
+  } else {
+    const namePolicy = useTSNamePolicy();
+
+    let tsFlags: TSSymbolFlags = TSSymbolFlags.None;
+    if (props.kind && props.kind === "type") {
+      tsFlags &= TSSymbolFlags.TypeSymbol;
+    }
+
+    sym = createTSSymbol({
+      name: namePolicy.getName(props.name!, props.nameKind!),
+      refkey: props.refkey,
+      export: props.export,
+      default: props.default,
+      flags: props.flags,
+      tsFlags,
+      metadata: props.metadata,
+    });
   }
 
-  const sym = createTSSymbol({
-    name: namePolicy.getName(props.name, props.nameKind),
-    refkey: props.refkey,
-    export: props.export,
-    default: props.default,
-    flags: props.flags,
-    tsFlags,
-    metadata: props.metadata,
-  });
-
-  let children: Children;
+  function withMemberScope(children: Children) {
+    return <MemberScope owner={sym}>{children}</MemberScope>;
+  }
+
+  function withPrivateMemberScope(children: Children) {
+    const context: PrivateScopeContext = {
+      instanceMembers: sym.privateMemberScope!,
+      staticMembers: sym.privateStaticMemberScope!,
+    };
+    return (
+      <PrivateScopeContext.Provider value={context}>
+        {children}
+      </PrivateScopeContext.Provider>
+    );
+  }
+
+  let children: Children = () => props.children;
+
   if (sym.flags & OutputSymbolFlags.MemberContainer) {
-    children = <MemberScope owner={sym}>{props.children}</MemberScope>;
-  } else {
-    children = () => props.children;
+    children = withMemberScope(children);
+  }
+
+  if (sym.tsFlags & TSSymbolFlags.PrivateMemberContainer) {
+    children = withPrivateMemberScope(children);
   }
 
   return (
diff --git a/packages/typescript/src/components/EnumMember.tsx b/packages/typescript/src/components/EnumMember.tsx
index 0184c1f..980116c 100644
--- a/packages/typescript/src/components/EnumMember.tsx
+++ b/packages/typescript/src/components/EnumMember.tsx
@@ -2,6 +2,7 @@ import { Children, OutputSymbolFlags, Refkey, Show } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
 import { createTSSymbol, TSOutputSymbol } from "../symbols/ts-output-symbol.js";
 import { JSDoc } from "./JSDoc.jsx";
+import { PropertyName } from "./PropertyName.jsx";
 import { ValueExpression } from "./ValueExpression.jsx";
 
 export interface EnumMemberProps {
@@ -55,7 +56,7 @@ export function EnumMember(props: EnumMemberProps) {
       metadata: props.metadata,
     });
   }
-  const nameCode = sym ? sym.name : name;
+  const actualName = sym ? sym.name : name;
   const valueCode =
     props.jsValue !== undefined ?
       <ValueExpression jsValue={props.jsValue} />
@@ -67,7 +68,7 @@ export function EnumMember(props: EnumMemberProps) {
         <JSDoc children={props.doc} />
         <hbr />
       </Show>
-      {nameCode}
+      <PropertyName name={actualName} />
       <Show when={valueCode !== undefined}> = {valueCode}</Show>
     </>
   );
diff --git a/packages/typescript/src/components/Interface.tsx b/packages/typescript/src/components/Interface.tsx
index 23b640f..3e0f7f8 100644
--- a/packages/typescript/src/components/Interface.tsx
+++ b/packages/typescript/src/components/Interface.tsx
@@ -1,8 +1,18 @@
-import { Block, Children, Name, Show } from "@alloy-js/core";
+import {
+  Block,
+  Children,
+  MemberDeclaration,
+  Name,
+  OutputSymbolFlags,
+  Refkey,
+  Show,
+} from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
 import { JSDoc } from "./JSDoc.jsx";
 
+import { PropertyName } from "./PropertyName.jsx";
+
 export interface InterfaceDeclarationProps extends BaseDeclarationProps {
   extends?: Children;
 }
@@ -18,6 +28,7 @@ export interface InterfaceDeclarationProps extends BaseDeclarationProps {
  */
 export function InterfaceDeclaration(props: InterfaceDeclarationProps) {
   const extendsPart = props.extends ? <> extends {props.extends}</> : "";
+  const flags = OutputSymbolFlags.StaticMemberContainer;
 
   return (
     <>
@@ -25,7 +36,7 @@ export function InterfaceDeclaration(props: InterfaceDeclarationProps) {
         <JSDoc children={props.doc} />
         <hbr />
       </Show>
-      <Declaration {...props} nameKind="interface">
+      <Declaration {...props} nameKind="interface" flags={flags}>
         interface <Name />
         {extendsPart}{" "}
         <InterfaceExpression>{props.children}</InterfaceExpression>
@@ -50,16 +61,23 @@ export interface InterfaceMemberProps {
   optional?: boolean;
   readonly?: boolean;
   doc?: Children;
+  refkey?: Refkey | Refkey[];
 }
 
 /**
  * Create a TypeScript interface member.
+ *
+ * An interface member can either provide a `name` prop to create a named
+ * property, or an `indexer` prop to define an indexer for the interface.
+ *
+ * The type of the member can be provided either as the `type` prop or as the
+ * children of the component.
  */
 export function InterfaceMember(props: InterfaceMemberProps) {
-  const namer = useTSNamePolicy();
   const type = props.type ?? props.children;
   const optionality = props.optional ? "?" : "";
   const readonly = props.readonly ? "readonly " : "";
+
   if (props.indexer) {
     return (
       <>
@@ -67,16 +85,32 @@ export function InterfaceMember(props: InterfaceMemberProps) {
       </>
     );
   } else {
-    return (
-      <>
-        <Show when={Boolean(props.doc)}>
-          <JSDoc children={props.doc} />
-          <hbr />
-        </Show>
-        {readonly}
-        {namer.getName(props.name!, "interface-member")}
-        {optionality}: {type}
-      </>
-    );
+    const namer = useTSNamePolicy();
+    const name = namer.getName(props.name!, "interface-member");
+    if (props.refkey) {
+      return (
+        <MemberDeclaration static name={name} refkey={props.refkey}>
+          <Show when={Boolean(props.doc)}>
+            <JSDoc children={props.doc} />
+            <hbr />
+          </Show>
+          {readonly}
+          <PropertyName />
+          {optionality}: {type}
+        </MemberDeclaration>
+      );
+    } else {
+      return (
+        <>
+          <Show when={Boolean(props.doc)}>
+            <JSDoc children={props.doc} />
+            <hbr />
+          </Show>
+          {readonly}
+          <PropertyName name={name} />
+          {optionality}: {type}
+        </>
+      );
+    }
   }
 }
diff --git a/packages/typescript/src/components/ObjectExpression.tsx b/packages/typescript/src/components/ObjectExpression.tsx
index b482aa2..e56d63a 100644
--- a/packages/typescript/src/components/ObjectExpression.tsx
+++ b/packages/typescript/src/components/ObjectExpression.tsx
@@ -13,7 +13,9 @@ import {
   Switch,
   Wrap,
 } from "@alloy-js/core";
+import { useTSNamePolicy } from "../name-policy.js";
 import { createTSSymbol } from "../symbols/index.js";
+import { PropertyName } from "./PropertyName.jsx";
 import { ValueExpression } from "./ValueExpression.js";
 
 export interface ObjectExpressionProps {
@@ -96,7 +98,10 @@ export interface ObjectPropertyProps {
 export function ObjectProperty(props: ObjectPropertyProps) {
   let name;
   if (props.name) {
-    name = props.name;
+    const namer = useTSNamePolicy();
+    name = (
+      <PropertyName name={namer.getName(props.name, "object-member-data")} />
+    );
   } else if (props.nameExpression) {
     name = <>[{props.nameExpression}]</>;
   } else {
@@ -126,7 +131,7 @@ export function ObjectProperty(props: ObjectPropertyProps) {
     sym ? createAssignmentContext(sym) : undefined;
   return (
     <>
-      {sym ? sym.name : name}:{" "}
+      {name}:{" "}
       <AssignmentContext.Provider value={assignmentContext}>
         {value}
       </AssignmentContext.Provider>
diff --git a/packages/typescript/src/components/PropertyName.tsx b/packages/typescript/src/components/PropertyName.tsx
new file mode 100644
index 0000000..227f762
--- /dev/null
+++ b/packages/typescript/src/components/PropertyName.tsx
@@ -0,0 +1,50 @@
+import { MemberDeclarationContext, useContext } from "@alloy-js/core";
+import { TSOutputSymbol, TSSymbolFlags } from "../symbols/ts-output-symbol.js";
+import { isValidJSIdentifier } from "../utils.js";
+
+export interface PropertyNameProps {
+  /**
+   * The name of the property.
+   */
+  name?: string;
+
+  /**
+   * Whether the property is a private property. If `true`, the property will be
+   * prefixed with `#`.
+   */
+  private?: boolean;
+}
+
+/**
+ * A TypeScript property name for an interface, class, or object member. If the
+ * name is not a valid JavaScript identifier, it will be quoted. If a `name` prop
+ * is provided, it will be used as the property name. Otherwise, the name will be
+ * taken from the {@link (MemberDeclarationContext:variable)}.
+ */
+export function PropertyName(props: PropertyNameProps) {
+  if (props.name) {
+    if (props.private) {
+      return "#" + props.name;
+    }
+    return quoteIfNeeded(props.name);
+  } else {
+    const declSymbol = useContext(MemberDeclarationContext) as TSOutputSymbol;
+    if (!declSymbol) {
+      return "(no member declaration context)";
+    }
+
+    if (declSymbol.tsFlags & TSSymbolFlags.PrivateMember) {
+      return <>#{declSymbol.name}</>;
+    } else {
+      return <>{quoteIfNeeded(declSymbol.name)}</>;
+    }
+  }
+}
+
+function quoteIfNeeded(name: string) {
+  if (isValidJSIdentifier(name)) {
+    return name;
+  } else {
+    return `"${name.replace(/"/g, '\\"')}"`;
+  }
+}
diff --git a/packages/typescript/src/context/index.ts b/packages/typescript/src/context/index.ts
new file mode 100644
index 0000000..b6bcfbd
--- /dev/null
+++ b/packages/typescript/src/context/index.ts
@@ -0,0 +1,1 @@
+export * from "./private-scope.js";
diff --git a/packages/typescript/src/context/private-scope.ts b/packages/typescript/src/context/private-scope.ts
new file mode 100644
index 0000000..424374a
--- /dev/null
+++ b/packages/typescript/src/context/private-scope.ts
@@ -0,0 +1,23 @@
+import { ComponentContext, createContext, useContext } from "@alloy-js/core";
+import { TSMemberScope } from "../symbols/ts-member-scope.js";
+
+export interface PrivateScopeContext {
+  /**
+   * Scope for private static members.
+   */
+  staticMembers: TSMemberScope;
+  /**
+   * Scope for private instance members.
+   */
+  instanceMembers: TSMemberScope;
+}
+
+/**
+ * Provides scopes for instance and static private members.
+ */
+export const PrivateScopeContext: ComponentContext<PrivateScopeContext> =
+  createContext<PrivateScopeContext>();
+
+export function usePrivateScope() {
+  return useContext(PrivateScopeContext);
+}
diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts
index f4bc2ec..f4d34b6 100644
--- a/packages/typescript/src/index.ts
+++ b/packages/typescript/src/index.ts
@@ -1,5 +1,6 @@
 export * from "./builtins/index.js";
 export * from "./components/index.js";
+export * from "./context/index.js";
 export * from "./create-package.js";
 export * from "./name-conflict-resolver.js";
 export * from "./name-policy.js";
diff --git a/packages/typescript/src/symbols/reference.ts b/packages/typescript/src/symbols/reference.ts
index 693a3b9..0d6f7e9 100644
--- a/packages/typescript/src/symbols/reference.ts
+++ b/packages/typescript/src/symbols/reference.ts
@@ -5,13 +5,19 @@ import {
   resolve,
   untrack,
   useContext,
+  useMemberScope,
 } from "@alloy-js/core";
 import { usePackage } from "../components/PackageDirectory.jsx";
 import { SourceFileContext } from "../components/SourceFile.jsx";
+import {
+  PrivateScopeContext,
+  usePrivateScope,
+} from "../context/private-scope.js";
+import { isValidJSIdentifier } from "../utils.js";
 import { TSOutputScope } from "./scopes.js";
 import { TSMemberScope } from "./ts-member-scope.js";
 import { TSModuleScope } from "./ts-module-scope.js";
-import { TSOutputSymbol } from "./ts-output-symbol.js";
+import { TSOutputSymbol, TSSymbolFlags } from "./ts-output-symbol.js";
 import { TSPackageScope } from "./ts-package-scope.js";
 
 export function ref(refkey: Refkey): () => string {
@@ -19,6 +25,8 @@ export function ref(refkey: Refkey): () => string {
   const resolveResult = resolve<TSOutputScope, TSOutputSymbol>(
     refkey as Refkey,
   );
+  const currentScope = useMemberScope();
+  const currentPrivateScope = usePrivateScope();
 
   return memo(() => {
     if (resolveResult.value === undefined) {
@@ -27,7 +35,25 @@ export function ref(refkey: Refkey): () => string {
 
     const { targetDeclaration, pathDown, memberPath } = resolveResult.value;
 
-    validateSymbolReachable(pathDown);
+    // if we resolved a instance member, check if we should be able to access
+    // it.
+    if (targetDeclaration.flags & OutputSymbolFlags.InstanceMember) {
+      if (targetDeclaration.tsFlags & TSSymbolFlags.PrivateMember) {
+        if (currentPrivateScope?.instanceMembers !== targetDeclaration.scope) {
+          throw new Error(
+            "Cannot resolve private member symbols from a different scope",
+          );
+        }
+      } else {
+        if (currentScope?.instanceMembers !== targetDeclaration.scope) {
+          throw new Error(
+            "Cannot resolve member symbols from a different member scope",
+          );
+        }
+      }
+    }
+
+    validateSymbolReachable(pathDown, memberPath, currentPrivateScope);
 
     // Where the target declaration is relative to the referencing scope.
     // * package: target symbol is in a different package
@@ -87,32 +113,42 @@ export function ref(refkey: Refkey): () => string {
         memberPath[0] = localSymbol;
       }
 
-      return addThisPrefix(
-        targetDeclaration,
-        buildMemberExpression(memberPath),
-      );
+      return buildMemberExpression(memberPath);
     } else {
-      return addThisPrefix(
-        localSymbol ?? targetDeclaration,
-        localSymbol ? localSymbol.name : targetDeclaration.name,
-      );
+      return buildMemberExpression([localSymbol ?? targetDeclaration]);
     }
   });
 }
 
-function addThisPrefix(rootSymbol: TSOutputSymbol, name: string) {
-  if (rootSymbol.flags & OutputSymbolFlags.InstanceMember) {
-    return `this.${name}`;
+function buildMemberExpression(path: TSOutputSymbol[]) {
+  let memberExpr = "";
+
+  const base = path[0];
+  if (base.flags & OutputSymbolFlags.InstanceMember) {
+    memberExpr += "this";
   } else {
-    return name;
+    memberExpr += base.name;
+    path = path.slice(1);
   }
-}
 
-function buildMemberExpression(symbolPath: TSOutputSymbol[]) {
-  return symbolPath.map((sym) => sym.name).join(".");
+  for (const sym of path) {
+    if (sym.tsFlags & TSSymbolFlags.PrivateMember) {
+      memberExpr += `.#${sym.name}`;
+    } else if (isValidJSIdentifier(sym.name)) {
+      memberExpr += `.${sym.name}`;
+    } else {
+      memberExpr += `[${JSON.stringify(sym.name)}]`;
+    }
+  }
+
+  return memberExpr;
 }
 
-function validateSymbolReachable(path: TSOutputScope[]) {
+function validateSymbolReachable(
+  path: TSOutputScope[],
+  memberPath: TSOutputSymbol[] | undefined,
+  currentPrivateScope: PrivateScopeContext | undefined,
+) {
   for (const scope of path) {
     if (scope.kind === "function") {
       throw new Error(
@@ -120,4 +156,20 @@ function validateSymbolReachable(path: TSOutputScope[]) {
       );
     }
   }
+
+  if (memberPath) {
+    for (const sym of memberPath) {
+      // make sure we're not trying to access a static private from outside
+      // the static member scope.
+      if (
+        sym.flags & OutputSymbolFlags.StaticMember &&
+        sym.tsFlags & TSSymbolFlags.PrivateMember &&
+        currentPrivateScope?.staticMembers !== sym.scope
+      ) {
+        throw new Error(
+          "Cannot resolve private static member symbols from a different scope",
+        );
+      }
+    }
+  }
 }
diff --git a/packages/typescript/src/symbols/ts-member-scope.ts b/packages/typescript/src/symbols/ts-member-scope.ts
index 38fd461..dbed231 100644
--- a/packages/typescript/src/symbols/ts-member-scope.ts
+++ b/packages/typescript/src/symbols/ts-member-scope.ts
@@ -1,4 +1,4 @@
-import { Binder, OutputScope } from "@alloy-js/core";
+import { Binder, OutputScope, OutputScopeFlags } from "@alloy-js/core";
 import { TSOutputSymbol } from "./ts-output-symbol.js";
 
 export interface TSMemberScope extends OutputScope {
@@ -9,13 +9,17 @@ export interface TSMemberScope extends OutputScope {
 
 export function createTSMemberScope(
   binder: Binder,
-  parent: OutputScope,
+  parent: OutputScope | undefined,
   owner: TSOutputSymbol,
   isStatic: boolean = false,
 ): TSMemberScope {
   return binder.createScope<TSMemberScope>({
     kind: "member",
     name: "members",
+    flags:
+      isStatic ?
+        OutputScopeFlags.StaticMemberScope
+      : OutputScopeFlags.MemberScope,
     owner,
     parent,
     isStatic,
diff --git a/packages/typescript/src/symbols/ts-output-symbol.ts b/packages/typescript/src/symbols/ts-output-symbol.ts
index 3ed205c..481099b 100644
--- a/packages/typescript/src/symbols/ts-output-symbol.ts
+++ b/packages/typescript/src/symbols/ts-output-symbol.ts
@@ -6,14 +6,16 @@ import {
   useDefaultScope,
 } from "@alloy-js/core";
 import { TSOutputScope } from "./scopes.js";
-import { TSMemberScope } from "./ts-member-scope.js";
+import { createTSMemberScope, TSMemberScope } from "./ts-member-scope.js";
 
 // prettier-ignore
 export enum TSSymbolFlags {
-  None              = 0,
-  LocalImportSymbol = 1 << 0,
-  TypeSymbol        = 1 << 1,
-  ParameterSymbol   = 1 << 2,
+  None                   = 0,
+  LocalImportSymbol      = 1 << 0,
+  TypeSymbol             = 1 << 1,
+  ParameterSymbol        = 1 << 2,
+  PrivateMember          = 1 << 3,
+  PrivateMemberContainer = 1 << 4,
 }
 
 export interface TSOutputSymbol extends OutputSymbol {
@@ -21,7 +23,8 @@ export interface TSOutputSymbol extends OutputSymbol {
   export: boolean;
   default: boolean;
   tsFlags: TSSymbolFlags;
-  memberScope?: TSMemberScope;
+  privateMemberScope?: TSMemberScope;
+  privateStaticMemberScope?: TSMemberScope;
 }
 
 export interface createTsSymbolOptions {
@@ -45,7 +48,7 @@ export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
   }
 
   const binder = scope.binder;
-
+  const tsFlags = options.tsFlags ?? TSSymbolFlags.None;
   const sym = binder.createSymbol<TSOutputSymbol>({
     name: options.name,
     scope,
@@ -53,10 +56,20 @@ export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
     export: !!options.export,
     default: !!options.default,
     flags: options.flags ?? OutputSymbolFlags.None,
-    tsFlags: options.tsFlags ?? TSSymbolFlags.None,
+    tsFlags,
     metadata: options.metadata,
   });
 
+  if (tsFlags & TSSymbolFlags.PrivateMemberContainer) {
+    sym.privateMemberScope = createTSMemberScope(binder, undefined, sym);
+    sym.privateStaticMemberScope = createTSMemberScope(
+      binder,
+      undefined,
+      sym,
+      true,
+    );
+  }
+
   if (options.export && scope.kind === "module") {
     for (const refkey of sym.refkeys) {
       scope.exportedSymbols.set(refkey, sym);
diff --git a/packages/typescript/src/utils.ts b/packages/typescript/src/utils.ts
index 5831acf..f6b8cc9 100644
--- a/packages/typescript/src/utils.ts
+++ b/packages/typescript/src/utils.ts
@@ -31,3 +31,10 @@ export function getCallSignatureProps(
 
   return defaultProps(callSignatureProps, defaults);
 }
+
+const identifierRegex =
+  /^(?:[\p{ID_Start}$_])(?:[\p{ID_Continue}$\u200C\u200D])*$/u;
+
+export function isValidJSIdentifier(str: string) {
+  return identifierRegex.test(str);
+}


Test patch:
diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts
index 9fe93ab..143b9ac 100644
--- a/packages/core/test/symbols.test.ts
+++ b/packages/core/test/symbols.test.ts
@@ -296,31 +296,6 @@ describe("instance members", () => {
     expect(pathUp).toEqual([]);
     expect(memberPath).toEqual([instance]);
   });
-
-  it("doesn't resolve from outside the member scope", () => {
-    const binder = createOutputBinder();
-    const {
-      scopes: { root },
-      symbols: { instance },
-    } = createScopeTree(binder, {
-      root: {
-        symbols: {
-          root: {
-            flags: OutputSymbolFlags.InstanceMemberContainer,
-            instanceMembers: {
-              instance: {
-                flags: OutputSymbolFlags.InstanceMember,
-              },
-            },
-          },
-        },
-      },
-    });
-
-    expect(() =>
-      binder.resolveDeclarationByKey(root, undefined, instance.refkeys[0]),
-    ).toThrow(/Cannot resolve member symbols/);
-  });
 });
 
 describe("instantiating members", () => {
diff --git a/packages/typescript/test/class.test.tsx b/packages/typescript/test/class.test.tsx
index 2dc9469..695b71a 100644
--- a/packages/typescript/test/class.test.tsx
+++ b/packages/typescript/test/class.test.tsx
@@ -61,6 +61,8 @@ describe("instance members", () => {
 
   it("can be referenced", () => {
     const one = refkey();
+    const privateOne = refkey();
+
     const res = toSourceText(
       <ts.ClassDeclaration name="Foo">
         <StatementList>
@@ -68,6 +70,10 @@ describe("instance members", () => {
             1
           </ts.ClassField>
           <ts.ClassField name="two">{one} + 1</ts.ClassField>
+          <ts.ClassField name="one" refkey={privateOne} jsPrivate>
+            1
+          </ts.ClassField>
+          <ts.ClassField name="three">{privateOne} + 1</ts.ClassField>
         </StatementList>
       </ts.ClassDeclaration>,
     );
@@ -76,6 +82,8 @@ describe("instance members", () => {
       class Foo {
         one = 1;
         two = this.one + 1;
+        #one = 1;
+        three = this.#one + 1;
       }
     `);
   });
@@ -97,6 +105,64 @@ describe("instance members", () => {
       );
     }).toThrow(/Cannot resolve member symbols/);
   });
+
+  it("cannot be referenced outside the class when protected", () => {
+    const one = refkey();
+    expect(() => {
+      toSourceText(
+        <>
+          <ts.ClassDeclaration name="Foo">
+            <StatementList>
+              <ts.ClassField name="one" refkey={one} protected>
+                1
+              </ts.ClassField>
+            </StatementList>
+          </ts.ClassDeclaration>
+          {one}
+        </>,
+      );
+    }).toThrow(/Cannot resolve member symbols/);
+  });
+
+  it("cannot be referenced outside the class when private", () => {
+    const one = refkey();
+    expect(() => {
+      toSourceText(
+        <>
+          <ts.ClassDeclaration name="Foo">
+            <StatementList>
+              <ts.ClassField name="one" refkey={one} jsPrivate>
+                1
+              </ts.ClassField>
+            </StatementList>
+          </ts.ClassDeclaration>
+          {one}
+        </>,
+      );
+    }).toThrow(/Cannot resolve private member symbols/);
+  });
+
+  it("works with invalid identifier names", () => {
+    const one = refkey();
+
+    const res = toSourceText(
+      <ts.ClassDeclaration name="Foo">
+        <StatementList>
+          <ts.ClassField name="o-n-e" refkey={one}>
+            1
+          </ts.ClassField>
+          <ts.ClassField name="t-w-o">{one} + 1</ts.ClassField>
+        </StatementList>
+      </ts.ClassDeclaration>,
+    );
+
+    expect(res).toEqual(d`
+      class Foo {
+        "o-n-e" = 1;
+        "t-w-o" = this["o-n-e"] + 1;
+      }
+    `);
+  });
 });
 
 describe("static members", () => {
@@ -108,6 +174,10 @@ describe("static members", () => {
           <ts.ClassField static name="one" refkey={one}>
             1
           </ts.ClassField>
+
+          <ts.ClassField static name="two" jsPrivate>
+            2
+          </ts.ClassField>
         </StatementList>
       </ts.ClassDeclaration>,
     );
@@ -115,12 +185,14 @@ describe("static members", () => {
     expect(res).toBe(d`
       class Foo {
         static one = 1;
+        static #two = 2;
       }
     `);
   });
 
   it("can be referenced", () => {
     const one = refkey();
+    const two = refkey();
     const res = toSourceText(
       <>
         <ts.ClassDeclaration name="Foo">
@@ -128,6 +200,10 @@ describe("static members", () => {
             <ts.ClassField static name="one" refkey={one}>
               1
             </ts.ClassField>
+            <ts.ClassField static name="two" refkey={two} jsPrivate>
+              2
+            </ts.ClassField>
+            <ts.ClassMethod name="getTwo">return {two};</ts.ClassMethod>
           </StatementList>
         </ts.ClassDeclaration>
         <hbr />
@@ -138,10 +214,34 @@ describe("static members", () => {
     expect(res).toBe(d`
       class Foo {
         static one = 1;
+        static #two = 2;
+        getTwo() {
+          return Foo.#two;
+        };
       }
       Foo.one;
     `);
   });
+
+  it("cannot be referenced outside the class when private", () => {
+    const two = refkey();
+
+    expect(() =>
+      toSourceText(
+        <>
+          <ts.ClassDeclaration name="Foo">
+            <StatementList>
+              <ts.ClassField static name="two" refkey={two} jsPrivate>
+                2
+              </ts.ClassField>
+            </StatementList>
+          </ts.ClassDeclaration>
+          <hbr />
+          {two};
+        </>,
+      ),
+    ).toThrow(/Cannot resolve/);
+  });
 });
 
 describe("instance methods", () => {
diff --git a/packages/typescript/test/enum.test.tsx b/packages/typescript/test/enum.test.tsx
index df040d8..34774c4 100644
--- a/packages/typescript/test/enum.test.tsx
+++ b/packages/typescript/test/enum.test.tsx
@@ -152,6 +152,7 @@ it("uses the naming policy", () => {
 describe("symbols", () => {
   it("can reference enum members by refkey", () => {
     const memberOne = refkey();
+    const memberTwo = refkey();
 
     expect(
       <Output>
@@ -159,19 +160,25 @@ describe("symbols", () => {
           <ts.EnumDeclaration name="MyEnum">
             <ts.CommaList>
               <ts.EnumMember name="foo" jsValue="1" refkey={memberOne} />
-              <ts.EnumMember name="bar" jsValue={2} />
+              <ts.EnumMember
+                name="mangled-name"
+                jsValue={2}
+                refkey={memberTwo}
+              />
             </ts.CommaList>
           </ts.EnumDeclaration>
           <hbr />
-          {memberOne};
+          {memberOne};<hbr />
+          {memberTwo};
         </ts.SourceFile>
       </Output>,
     ).toRenderTo(`
       enum MyEnum {
         foo = "1",
-        bar = 2,
+        "mangled-name" = 2,
       }
       MyEnum.foo;
+      MyEnum["mangled-name"];
     `);
   });
 });
diff --git a/packages/typescript/test/interface.test.tsx b/packages/typescript/test/interface.test.tsx
index 83966ef..186a4fc 100644
--- a/packages/typescript/test/interface.test.tsx
+++ b/packages/typescript/test/interface.test.tsx
@@ -132,3 +132,17 @@ it("supports the naming policy", () => {
     }
   `);
 });
+
+it("handles invalid identifier names", () => {
+  const res = toSourceText(
+    <ts.InterfaceExpression>
+      <ts.InterfaceMember name="invalid-name" type="string" />;
+    </ts.InterfaceExpression>,
+  );
+
+  expect(res).toEqual(d`
+    {
+      "invalid-name": string;
+    }
+  `);
+});
diff --git a/packages/typescript/test/value-expression.test.tsx b/packages/typescript/test/value-expression.test.tsx
index d7a3629..d2f3bbb 100644
--- a/packages/typescript/test/value-expression.test.tsx
+++ b/packages/typescript/test/value-expression.test.tsx
@@ -28,6 +28,15 @@ it.each([
       }
     `,
   ],
+  [
+    { "mangled-name": 1, "@pagination": 2 },
+    d`
+      {
+        "mangled-name": 1,
+        "@pagination": 2,
+      }
+    `,
+  ],
 ])("works - %o => %s", (jsValue, expectedSource) => {
   expect(toSourceText(<ValueExpression jsValue={jsValue} />)).toBe(
     expectedSource,