Repository: alloy-framework/alloy
Pull request #117 (2025-04-16 22:39:00)

Add nullish to TS Symbol flags
* Adds a nullish flag to TSSymbolFlag
* Automatically tag optional class and interface members as nullish
* Update reference member expression to handle nullish
* Update MemberExpression component to handle nullish

Todo:
* Explore if we need automatic flagging for function parameters

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

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

### `@alloy-js/typescript` - _feature_ [✏️](https://github.com/joheredi/alloy/edit/feature/nullish-symbol/.chronus/changes/feature-nullish-symbol-2025-3-16-22-36-17.md?pr=/alloy-framework/alloy/pull/117)
> Add nullish to TS Symbol flags
</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/b54a83cb-af34-4f66-83c7-acb698351ec1)


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

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

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

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

</p></details>
      
    

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

```
npm i https://pkg.pr.new/alloy-framework/alloy/@alloy-js/cli@117
```

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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


Patch:
diff --git a/.chronus/changes/feature-nullish-symbol-2025-3-16-22-36-17.md b/.chronus/changes/feature-nullish-symbol-2025-3-16-22-36-17.md
new file mode 100644
index 0000000..f82eeea
--- /dev/null
+++ b/.chronus/changes/feature-nullish-symbol-2025-3-16-22-36-17.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Add nullish to TS Symbol flags
\ No newline at end of file
diff --git a/packages/typescript/src/components/ClassDeclaration.tsx b/packages/typescript/src/components/ClassDeclaration.tsx
index 0373ca5..12ce0a0 100644
--- a/packages/typescript/src/components/ClassDeclaration.tsx
+++ b/packages/typescript/src/components/ClassDeclaration.tsx
@@ -22,6 +22,7 @@ import { BaseDeclarationProps, Declaration } from "./Declaration.jsx";
 import { JSDoc } from "./JSDoc.jsx";
 import { JSDocParams } from "./JSDocParam.jsx";
 import { PropertyName } from "./PropertyName.jsx";
+import { TypeRefContext } from "./TypeRefContext.jsx";
 
 export interface ClassDeclarationProps extends BaseDeclarationProps {
   extends?: Children;
@@ -101,6 +102,7 @@ export interface ClassMemberProps {
   static?: boolean;
   children?: Children;
   doc?: Children;
+  nullish?: boolean;
 }
 
 export function ClassMember(props: ClassMemberProps) {
@@ -128,12 +130,19 @@ export function ClassMember(props: ClassMemberProps) {
       : memberScope.instanceMembers!) as TSOutputScope;
   }
 
+  let tsFlags =
+    props.jsPrivate ? TSSymbolFlags.PrivateMember : TSSymbolFlags.None;
+
+  if (props.nullish) {
+    tsFlags |= TSSymbolFlags.Nullish;
+  }
+
   const sym = createTSSymbol({
     name,
     scope,
     refkey: props.refkey,
     flags,
-    tsFlags: props.jsPrivate ? TSSymbolFlags.PrivateMember : TSSymbolFlags.None,
+    tsFlags,
   });
 
   return (
@@ -155,15 +164,22 @@ export function ClassMember(props: ClassMemberProps) {
 
 export interface ClassFieldProps extends ClassMemberProps {
   type?: Children;
+  optional?: boolean;
   children?: Children;
 }
 
 export function ClassField(props: ClassFieldProps) {
-  const typeSection = props.type && <>: {props.type}</>;
+  const optionality = props.optional ? "?" : "";
+  const typeSection = props.type && (
+    <>
+      {optionality}: <TypeRefContext>{props.type}</TypeRefContext>
+    </>
+  );
   const initializerSection = props.children && <> = {props.children}</>;
+  const nullish = props.nullish ?? props.optional;
 
   return (
-    <ClassMember {...props}>
+    <ClassMember {...props} nullish={nullish}>
       <PropertyName />
       {typeSection}
       {initializerSection}
diff --git a/packages/typescript/src/components/FunctionBase.tsx b/packages/typescript/src/components/FunctionBase.tsx
index 8beceb1..98d78b2 100644
--- a/packages/typescript/src/components/FunctionBase.tsx
+++ b/packages/typescript/src/components/FunctionBase.tsx
@@ -189,13 +189,18 @@ function normalizeAndDeclareParameters(
     });
   } else {
     return (parameters as ParameterDescriptor[]).map((param) => {
+      const nullishFlag =
+        (param.nullish ?? param.optional) ?
+          TSSymbolFlags.Nullish
+        : TSSymbolFlags.None;
+
       const symbol = createTSSymbol({
         name: namePolicy.getName(
           param.name,
           flags & TSSymbolFlags.TypeSymbol ? "type" : "parameter",
         ),
         refkey: param.refkey,
-        tsFlags: flags,
+        tsFlags: flags | nullishFlag,
         metadata: param.metadata,
       });
 
diff --git a/packages/typescript/src/components/Interface.tsx b/packages/typescript/src/components/Interface.tsx
index 842049c..b1ae0f6 100644
--- a/packages/typescript/src/components/Interface.tsx
+++ b/packages/typescript/src/components/Interface.tsx
@@ -83,6 +83,7 @@ export interface InterfaceMemberProps {
   type?: Children;
   children?: Children;
   optional?: boolean;
+  nullish?: boolean;
   readonly?: boolean;
   doc?: Children;
   refkey?: Refkey | Refkey[];
@@ -121,6 +122,7 @@ export function InterfaceMember(props: InterfaceMemberProps) {
           kind="type"
           nameKind="interface-member"
           flags={OutputSymbolFlags.StaticMember}
+          nullish={props.nullish ?? props.optional}
           refkey={props.refkey}
         >
           <Show when={Boolean(props.doc)}>
diff --git a/packages/typescript/src/components/MemberDeclaration.tsx b/packages/typescript/src/components/MemberDeclaration.tsx
index 2b5961a..e98cbb1 100644
--- a/packages/typescript/src/components/MemberDeclaration.tsx
+++ b/packages/typescript/src/components/MemberDeclaration.tsx
@@ -40,6 +40,10 @@ export interface MemberDeclarationProps
    * The symbol to use for this memberdeclaration.
    */
   symbol?: TSOutputSymbol;
+  /**
+   * Whether this member can be null or undefined.
+   */
+  nullish?: boolean;
 }
 
 export function MemberDeclaration(props: Readonly<MemberDeclarationProps>) {
@@ -50,7 +54,9 @@ export function MemberDeclaration(props: Readonly<MemberDeclarationProps>) {
   } else {
     const namePolicy = useTSNamePolicy();
 
-    let tsFlags: TSSymbolFlags = TSSymbolFlags.None;
+    let tsFlags: TSSymbolFlags =
+      props.nullish ? TSSymbolFlags.Nullish : TSSymbolFlags.None;
+
     if (props.kind === "type") {
       tsFlags |= TSSymbolFlags.TypeSymbol;
     }
diff --git a/packages/typescript/src/components/MemberExpression.tsx b/packages/typescript/src/components/MemberExpression.tsx
index 788c2e4..041e277 100644
--- a/packages/typescript/src/components/MemberExpression.tsx
+++ b/packages/typescript/src/components/MemberExpression.tsx
@@ -11,6 +11,7 @@ import {
   toRef,
   useBinder,
 } from "@alloy-js/core";
+import { TSOutputSymbol, TSSymbolFlags } from "../symbols/ts-output-symbol.js";
 import { isValidJSIdentifier } from "../utils.js";
 
 export interface MemberExpressionProps {
@@ -78,7 +79,9 @@ export function MemberExpression(props: MemberExpressionProps): Children {
       const symbolRef = binder.getSymbolForRefkey(partProps.refkey);
       part.base = computed(() => {
         if (symbolRef.value) {
-          part.nullish.value = !!symbolRef.value.metadata.nullish;
+          part.nullish.value = !!(
+            (symbolRef.value as TSOutputSymbol).tsFlags & TSSymbolFlags.Nullish
+          );
           part.accessStyle.value = accessStyleForMemberName(
             symbolRef.value.name,
           );
@@ -88,7 +91,9 @@ export function MemberExpression(props: MemberExpressionProps): Children {
         }
       });
     } else if (partProps.symbol) {
-      part.nullish.value = !!partProps.symbol.metadata.nullish;
+      part.nullish.value = !!(
+        (partProps.symbol as TSOutputSymbol).tsFlags & TSSymbolFlags.Nullish
+      );
       part.accessStyle = computed(() => {
         return accessStyleForMemberName(partProps.symbol!.name);
       });
diff --git a/packages/typescript/src/components/VarDeclaration.tsx b/packages/typescript/src/components/VarDeclaration.tsx
index acd5eb5..76ccbaf 100644
--- a/packages/typescript/src/components/VarDeclaration.tsx
+++ b/packages/typescript/src/components/VarDeclaration.tsx
@@ -7,7 +7,7 @@ import {
   Show,
 } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
-import { createTSSymbol } from "../symbols/index.js";
+import { createTSSymbol, TSSymbolFlags } from "../symbols/index.js";
 import { BaseDeclarationProps } from "./Declaration.js";
 import { JSDoc } from "./JSDoc.jsx";
 import { TypeRefContext } from "./TypeRefContext.jsx";
@@ -18,6 +18,7 @@ export interface VarDeclarationProps extends BaseDeclarationProps {
   var?: boolean;
   initializer?: Children;
   type?: Children;
+  nullish?: boolean;
 }
 
 export function VarDeclaration(props: VarDeclarationProps) {
@@ -34,6 +35,7 @@ export function VarDeclaration(props: VarDeclarationProps) {
     default: props.default,
     export: props.export,
     metadata: props.metadata,
+    tsFlags: props.nullish ? TSSymbolFlags.Nullish : TSSymbolFlags.None,
   });
 
   const assignmentContext = createAssignmentContext(sym);
diff --git a/packages/typescript/src/parameter-descriptor.ts b/packages/typescript/src/parameter-descriptor.ts
index 2856825..ce95ebd 100644
--- a/packages/typescript/src/parameter-descriptor.ts
+++ b/packages/typescript/src/parameter-descriptor.ts
@@ -24,6 +24,13 @@ export interface ParameterDescriptorBase {
    */
   readonly optional?: boolean;
 
+  /**
+   * Whether the parameter is can be null or undefined.
+   * when optional is true, this will be set to true by default,
+   * but explicitly setting this to false will override that.
+   */
+  readonly nullish?: boolean;
+
   /**
    * Arbitrary metadata for the parameter symbol.
    */
diff --git a/packages/typescript/src/symbols/reference.ts b/packages/typescript/src/symbols/reference.ts
index 5cf838e..a60e632 100644
--- a/packages/typescript/src/symbols/reference.ts
+++ b/packages/typescript/src/symbols/reference.ts
@@ -139,17 +139,21 @@ function buildMemberExpression(path: TSOutputSymbol[]) {
     path = path.slice(1);
   }
 
+  let prevNullish = base.tsFlags & TSSymbolFlags.Nullish;
   for (const sym of path) {
+    const optional = prevNullish ? "?" : "";
     if (sym.tsFlags & TSSymbolFlags.PrivateMember) {
-      memberExpr += `.#${sym.name}`;
+      memberExpr += `${optional}.#${sym.name}`;
     } else if (
       isValidJSIdentifier(sym.name) &&
       !(sym.tsFlags & TSSymbolFlags.TypeSymbol)
     ) {
-      memberExpr += `.${sym.name}`;
+      memberExpr += `${optional}.${sym.name}`;
     } else {
-      memberExpr += `[${JSON.stringify(sym.name)}]`;
+      const joiner = sym.tsFlags & TSSymbolFlags.Nullish ? "?." : "";
+      memberExpr += `${joiner}[${JSON.stringify(sym.name)}]`;
     }
+    prevNullish = sym.tsFlags & TSSymbolFlags.Nullish;
   }
 
   return memberExpr;
diff --git a/packages/typescript/src/symbols/ts-output-symbol.ts b/packages/typescript/src/symbols/ts-output-symbol.ts
index 481099b..6fd4300 100644
--- a/packages/typescript/src/symbols/ts-output-symbol.ts
+++ b/packages/typescript/src/symbols/ts-output-symbol.ts
@@ -16,6 +16,7 @@ export enum TSSymbolFlags {
   ParameterSymbol        = 1 << 2,
   PrivateMember          = 1 << 3,
   PrivateMemberContainer = 1 << 4,
+  Nullish = 1 << 5,
 }
 
 export interface TSOutputSymbol extends OutputSymbol {


Test patch:
diff --git a/packages/typescript/test/member-expression.test.tsx b/packages/typescript/test/member-expression.test.tsx
index daf72b4..ef6c9eb 100644
--- a/packages/typescript/test/member-expression.test.tsx
+++ b/packages/typescript/test/member-expression.test.tsx
@@ -1,9 +1,19 @@
-import { refkey, StatementList } from "@alloy-js/core";
+import { code, List, refkey, StatementList } from "@alloy-js/core";
 import { d } from "@alloy-js/core/testing";
 import { describe, expect, it } from "vitest";
 import { MemberExpression } from "../src/components/MemberExpression.jsx";
-import { ObjectExpression } from "../src/components/stc/index.js";
+import {
+  InterfaceMember,
+  ObjectExpression,
+} from "../src/components/stc/index.js";
 import { VarDeclaration } from "../src/components/VarDeclaration.jsx";
+import {
+  ClassDeclaration,
+  ClassField,
+  FunctionDeclaration,
+  InterfaceDeclaration,
+  ParameterDescriptor,
+} from "../src/index.js";
 import { toSourceText } from "./utils.js";
 
 it("renders basic member expression with dot notation", () => {
@@ -143,6 +153,165 @@ describe("with refkeys", () => {
     `);
   });
 
+  it("handles nullish symbols correctly", () => {
+    // Note that this is deliberately producing invalid references (it's just easiest to test with VarDeclaration).
+    const rk1 = refkey();
+    const rk2 = refkey();
+    expect(
+      toSourceText(
+        <StatementList>
+          <VarDeclaration name="test1" refkey={rk1} initializer={1} nullish />
+          <VarDeclaration name="test1" refkey={rk2} initializer={2} nullish />
+          <MemberExpression>
+            <MemberExpression.Part refkey={rk1} />
+            <MemberExpression.Part refkey={rk2} />
+          </MemberExpression>
+        </StatementList>,
+      ),
+    ).toBe(d`
+      const test1 = 1;
+      const test1_2 = 2;
+      test1?.test1_2;
+    `);
+  });
+
+  it("handles optional parameters correctly", () => {
+    const fooRef = refkey();
+    const modelRef = refkey();
+    const parameters: ParameterDescriptor[] = [
+      { name: "foo", optional: true, refkey: fooRef, type: modelRef },
+    ];
+    const messageRef = refkey();
+    const template = (
+      <List hardline>
+        <InterfaceDeclaration name="Model" refkey={modelRef}>
+          <InterfaceMember name="bar" refkey={refkey()} type="string" />
+        </InterfaceDeclaration>
+        <FunctionDeclaration name="fooFunction" parameters={parameters}>
+          <StatementList>
+            <VarDeclaration
+              name="message"
+              const
+              refkey={messageRef}
+              initializer={
+                <MemberExpression>
+                  <MemberExpression.Part refkey={fooRef} />
+                  <MemberExpression.Part id="bar" />
+                </MemberExpression>
+              }
+            />
+            <>console.log({messageRef})</>
+          </StatementList>
+        </FunctionDeclaration>
+      </List>
+    );
+
+    expect(toSourceText(template)).toBe(d`
+      interface Model {
+        bar: string
+      }
+      function fooFunction(foo?: Model) {
+        const message = foo?.bar;
+        console.log(message);
+      }
+    `);
+  });
+
+  it("handles nullish parameters correctly", () => {
+    const fooRef = refkey();
+    const modelRef = refkey();
+    const parameters: ParameterDescriptor[] = [
+      {
+        name: "foo",
+        nullish: true,
+        refkey: fooRef,
+        type: <>{modelRef} | null</>,
+      },
+    ];
+    const messageRef = refkey();
+    const template = (
+      <List hardline>
+        <InterfaceDeclaration name="Model" refkey={modelRef}>
+          <InterfaceMember name="bar" refkey={refkey()} type="string" />
+        </InterfaceDeclaration>
+        <FunctionDeclaration name="fooFunction" parameters={parameters}>
+          <StatementList>
+            <VarDeclaration
+              name="message"
+              const
+              refkey={messageRef}
+              initializer={
+                <MemberExpression>
+                  <MemberExpression.Part refkey={fooRef} />
+                  <MemberExpression.Part id="bar" />
+                </MemberExpression>
+              }
+            />
+            <>console.log({messageRef})</>
+          </StatementList>
+        </FunctionDeclaration>
+      </List>
+    );
+
+    expect(toSourceText(template)).toBe(d`
+      interface Model {
+        bar: string
+      }
+      function fooFunction(foo: Model | null) {
+        const message = foo?.bar;
+        console.log(message);
+      }
+    `);
+  });
+
+  it("handles nullish class member correctly", () => {
+    const classRefkey = refkey();
+    const interfaceRefkey = refkey();
+    const interfaceMemberRefkey = refkey();
+    const classMemberRefkey = refkey();
+    const instanceRefkey = refkey();
+    expect(
+      toSourceText(
+        <List hardline>
+          <InterfaceDeclaration name="Bar" refkey={interfaceRefkey}>
+            <InterfaceMember
+              name="prop1"
+              refkey={interfaceMemberRefkey}
+              type={"string"}
+            />
+          </InterfaceDeclaration>
+          <ClassDeclaration name="Foo" refkey={classRefkey}>
+            <ClassField
+              name="test1"
+              refkey={classMemberRefkey}
+              nullish
+              type={interfaceRefkey}
+            />
+          </ClassDeclaration>
+          <VarDeclaration
+            name="inst"
+            refkey={instanceRefkey}
+            initializer={code`new ${classRefkey}();`}
+          />
+          <MemberExpression>
+            <MemberExpression.Part refkey={instanceRefkey} />
+            <MemberExpression.Part refkey={classMemberRefkey} />
+            <MemberExpression.Part refkey={interfaceMemberRefkey} />
+          </MemberExpression>
+        </List>,
+      ),
+    ).toBe(d`
+       interface Bar {
+         prop1: string
+       }
+       class Foo {
+         test1: Bar
+       }
+       const inst = new Foo();
+       inst.test1?.prop1
+    `);
+  });
+
   it("handles late resolved refkeys correctly", () => {
     const rk1 = refkey();
     const rk2 = refkey();