Repository: alloy-framework/alloy
Pull request #80 (2025-04-06 23:07:40)

Implement member expr
Formatting is not yet implemented, but this shows how we can build complex member expressions from parts.

Alternative to #70.

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

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

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


</details>

pkg-pr-new[bot]: 
[Open in StackBlitz](https://pkg.pr.new/template/3874b05f-087c-4811-a88f-c82c8b854438)


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

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

</p></details>
      
    

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

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

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

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

</p></details>
      
    

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

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

</p></details>
      
    

_commit: <a href="https://github.com/alloy-framework/alloy/runs/40066779707"><code>1b96da2</code></a>_


Patch:
diff --git a/.chronus/changes/member-expr-2025-3-6-19-10-36.md b/.chronus/changes/member-expr-2025-3-6-19-10-36.md
new file mode 100644
index 0000000..2957a0e
--- /dev/null
+++ b/.chronus/changes/member-expr-2025-3-6-19-10-36.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Add getSymbolForRefkey to binder.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-2025-3-6-19-11-38.md b/.chronus/changes/member-expr-2025-3-6-19-11-38.md
new file mode 100644
index 0000000..1d93782
--- /dev/null
+++ b/.chronus/changes/member-expr-2025-3-6-19-11-38.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+isComponentCreator now takes an optional Component parameter which allows you to check if a child is a specific component.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-2025-3-6-19-12-58.md b/.chronus/changes/member-expr-2025-3-6-19-12-58.md
new file mode 100644
index 0000000..381a8cb
--- /dev/null
+++ b/.chronus/changes/member-expr-2025-3-6-19-12-58.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Add MemberExpression component which produces formatted member expressions and applies conditional access operators when needed.
\ No newline at end of file
diff --git a/.chronus/changes/member-expr-2025-3-6-19-9-49.md b/.chronus/changes/member-expr-2025-3-6-19-9-49.md
new file mode 100644
index 0000000..263ac91
--- /dev/null
+++ b/.chronus/changes/member-expr-2025-3-6-19-9-49.md
@@ -0,0 +1,7 @@
+---
+changeKind: feature
+packages:
+  - "@alloy-js/core"
+---
+
+Export `toRef` and `toRefs` utility functions for constructing refs from reactives.
\ No newline at end of file
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index 78bd741..b5fb803 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -317,6 +317,10 @@ export interface Binder {
     key: Refkey,
   ): Ref<ResolutionResult<TScope, TSymbol> | undefined>;
 
+  getSymbolForRefkey<TSymbol extends OutputSymbol>(
+    refkey: Refkey,
+  ): Ref<TSymbol | undefined>;
+
   /**
    * Find a symbol with a given name in the given scope. Returns a ref
    * for the symbol, such that when the symbol is available, the ref value
@@ -426,6 +430,7 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     createSymbol,
     deleteSymbol,
     resolveDeclarationByKey,
+    getSymbolForRefkey,
     addStaticMembersToSymbol,
     addInstanceMembersToSymbol,
     instantiateSymbolInto,
@@ -801,6 +806,25 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     }
   }
 
+  function getSymbolForRefkey<TSymbol extends OutputSymbol>(
+    refkey: Refkey,
+  ): Ref<TSymbol | undefined> {
+    const symbol = knownDeclarations.get(refkey);
+    if (symbol) {
+      return shallowRef(symbol as TSymbol);
+    } else {
+      if (waitingDeclarations.has(refkey)) {
+        return waitingDeclarations.get(refkey)! as ShallowRef<
+          TSymbol | undefined
+        >;
+      } else {
+        const declSignal = shallowRef();
+        waitingDeclarations.set(refkey, declSignal);
+        return declSignal;
+      }
+    }
+  }
+
   function waitForRefkey<
     TScope extends OutputScope,
     TSymbol extends OutputSymbol,
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index d38135a..8d7ac5a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -6,6 +6,8 @@ export {
   shallowReactive,
   shallowRef,
   toRaw,
+  toRef,
+  toRefs,
   type Ref,
 } from "@vue/reactivity";
 export * from "./binder.js";
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index 2bad674..9e2823e 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -220,10 +220,11 @@ export interface Component<TProps = Props> {
   (props: TProps): Children;
   tag?: symbol;
 }
+
 export interface ComponentCreator<TProps = Props> {
   component: Component<TProps>;
   (): Children;
-  props: Props;
+  props: TProps;
   tag?: symbol;
 }
 
@@ -284,8 +285,14 @@ function inspectProps(props: Props) {
 
 // These can be removed with a smarter transform that encodes the information we
 // need in the compiled JSX output.
-export function isComponentCreator(item: unknown): item is ComponentCreator {
-  return typeof item === "function" && (item as any).component;
+export function isComponentCreator<TProps = any>(
+  item: unknown,
+  component?: Component<TProps>,
+): item is ComponentCreator<TProps> {
+  if (!component) {
+    return typeof item === "function" && (item as any).component;
+  }
+  return typeof item === "function" && (item as any).component === component;
 }
 
 /**
diff --git a/packages/core/src/stc.ts b/packages/core/src/stc.ts
index 9ac1f27..82caf58 100644
--- a/packages/core/src/stc.ts
+++ b/packages/core/src/stc.ts
@@ -32,9 +32,9 @@ export function stc<T extends {}>(
   Component: ComponentDefinition<T>,
 ): StcSignature<T> {
   return (...args) => {
-    const fn: StcComponentCreator<T> = () => Component(args[0] as any);
+    const fn: StcComponentCreator<T> = (() => Component(args[0] as T)) as any;
     fn.component = Component;
-    fn.props = args[0]!;
+    fn.props = args[0]! as T;
     fn.code = (template, ...substitutions): ComponentCreator<T> => {
       const propsWithChildren = {
         ...(args[0] ?? {}),
@@ -43,7 +43,7 @@ export function stc<T extends {}>(
 
       const fn = () => Component(propsWithChildren as any);
       fn.component = Component;
-      fn.props = args[0]!;
+      fn.props = args[0]! as T;
       return fn;
     };
     fn.text = (template, ...substitutions) => {
@@ -54,7 +54,7 @@ export function stc<T extends {}>(
 
       const fn = () => Component(propsWithChildren as any);
       fn.component = Component;
-      fn.props = args[0]!;
+      fn.props = args[0]! as T;
       return fn;
     };
     fn.children = (...children: Children[]): ComponentCreator<T> => {
@@ -65,7 +65,7 @@ export function stc<T extends {}>(
 
       const fn = () => Component(propsWithChildren as any);
       fn.component = Component;
-      fn.props = args[0]!;
+      fn.props = args[0]! as T;
       return fn;
     };
 
diff --git a/packages/typescript/src/components/MemberExpression.tsx b/packages/typescript/src/components/MemberExpression.tsx
new file mode 100644
index 0000000..b2d3263
--- /dev/null
+++ b/packages/typescript/src/components/MemberExpression.tsx
@@ -0,0 +1,237 @@
+import {
+  Children,
+  childrenArray,
+  computed,
+  For,
+  isComponentCreator,
+  OutputSymbol,
+  Ref,
+  ref,
+  Refkey,
+  toRef,
+  useBinder,
+} from "@alloy-js/core";
+import { isValidJSIdentifier } from "../utils.js";
+
+export interface MemberExpressionProps {
+  children: Children;
+}
+
+interface PartDescriptor {
+  base: string | Ref<string>;
+  accessStyle: Ref<"dot" | "bracket">;
+  nullish: Ref<boolean>;
+  args?: Children[] | boolean;
+}
+
+/**
+ * Create a member expression from parts. Each part can provide one of
+ * the following:
+ *
+ * * **id**: The identifier for the member expression part
+ * * **refkey**: A refkey for a symbol whose name becomes the identifier
+ * * **symbol**: a symbol whose name becomes the identifier part
+ * * **args**: create a method call with the given args
+ *
+ * Additionally, a part can be `nullish`, which means subsequent parts
+ * will use the appropriate conditional access operator e.g. `?.`.
+ *
+ * @example
+ *
+ * ```tsx
+ * <MemberExpression>
+ *   <MemberExpression.Part id="base" />
+ *   <MemberExpression.Part refkey={rk} nullish />
+ *   <MemberExpression.Part symbol={sym} />
+ *   <MemberExpression.Part args={["hello", "world"]} />
+ * </MemberExpression>
+ * ```
+ *
+ * Assuming `rk` is a refkey to a symbol name "prop1", and `sym` is a symbol
+ * with a name of "prop2", this will render:
+ *
+ * ```ts
+ * base.prop1?.prop2("hello", "world")
+ * ```
+ */
+export function MemberExpression(props: MemberExpressionProps): Children {
+  const children = flattenMemberExpression(childrenArray(() => props.children));
+  const parts: PartDescriptor[] = [];
+  for (const child of children) {
+    if (!isComponentCreator(child, MemberExpression.Part)) {
+      // we ignore non-parts
+      continue;
+    }
+
+    const partProps = child.props;
+    const part: PartDescriptor = {
+      base: "",
+      accessStyle: ref("dot"),
+      nullish: ref(!!partProps.nullish),
+    };
+
+    if (partProps.id) {
+      part.base = partProps.id;
+      part.accessStyle.value = accessStyleForMemberName(partProps.id);
+    } else if (partProps.refkey) {
+      const binder = useBinder();
+      const symbolRef = binder.getSymbolForRefkey(partProps.refkey);
+      part.base = computed(() => {
+        if (symbolRef.value) {
+          part.nullish.value = !!symbolRef.value.metadata.nullish;
+          part.accessStyle.value = accessStyleForMemberName(
+            symbolRef.value.name,
+          );
+          return symbolRef.value.name;
+        } else {
+          return "<unresolved symbol>";
+        }
+      });
+    } else if (partProps.symbol) {
+      part.nullish.value = !!partProps.symbol.metadata.nullish;
+      part.accessStyle = computed(() => {
+        return accessStyleForMemberName(partProps.symbol!.name);
+      });
+      part.base = toRef(partProps.symbol, "name");
+    } else if (partProps.args) {
+      part.args = partProps.args;
+    }
+
+    parts.push(part);
+  }
+
+  if (parts.length === 0) {
+    return <></>;
+  }
+
+  // construct a member expression from the parts. When a part is nullish,
+  // and there is a subsequent part, we use `?.` instead of `.`. accessStyle determines
+  // whether we use dot or bracket notation.
+
+  const result = computed(() => {
+    const expression: Children[] = [];
+
+    for (let i = 0; i < parts.length; i++) {
+      const part = parts[i];
+      const base = typeof part.base === "string" ? part.base : part.base.value;
+      const hasName = base !== "";
+
+      if (i === 0) {
+        expression.push(base);
+      } else {
+        // Determine if we should use nullish operator from previous part
+        const prevPart = parts[i - 1];
+        const isNullish = prevPart.nullish.value;
+
+        // Handle parts with name vs. parts with only args differently
+        if (hasName) {
+          // Apply dot or bracket notation based on accessStyle for named parts
+          if (part.accessStyle.value === "dot") {
+            expression.push(
+              <group>
+                <indent>
+                  <sbr />
+                  {isNullish ? "?." : "."}
+                  {base}
+                </indent>
+              </group>,
+            );
+          } else {
+            // bracket notation - don't include the dot
+            expression.push(
+              <group>
+                {isNullish ? "?." : ""}[
+                <indent>
+                  <sbr />
+                  {JSON.stringify(base)}
+                </indent>
+                <sbr />]
+              </group>,
+            );
+          }
+        } else if (part.args !== undefined) {
+          // For parts with only args (no name), append function call directly with appropriate nullish operator
+          expression.push(
+            <group>
+              {isNullish ? "?." : ""}
+              <group>
+                (
+                <For
+                  each={typeof part.args === "boolean" ? [] : (part.args ?? [])}
+                  comma
+                  line
+                >
+                  {(arg) => arg}
+                </For>
+                )
+              </group>
+            </group>,
+          );
+        }
+      }
+    }
+
+    return expression;
+  });
+  return <>{result.value}</>;
+}
+
+function accessStyleForMemberName(name: string) {
+  if (isValidJSIdentifier(name)) {
+    return "dot";
+  }
+  return "bracket";
+}
+
+function flattenMemberExpression(children: Children[]): Children[] {
+  const flattened: Children[] = [];
+  for (const child of children) {
+    if (isComponentCreator(child, MemberExpression)) {
+      flattened.push(
+        ...flattenMemberExpression(childrenArray(() => child.props.children)),
+      );
+    } else {
+      flattened.push(child);
+    }
+  }
+  return flattened;
+}
+
+interface MemberExpressionPartProps {
+  /**
+   * The identifier for this part of the member expression.
+   */
+  id?: string;
+  /**
+   * A refkey for a symbol whose name becomes the identifier.
+   */
+  refkey?: Refkey;
+  /**
+   * A symbol whose name becomes the identifier.
+   */
+  symbol?: OutputSymbol;
+  /**
+   * Arguments to construct a call expression.
+   */
+  args?: Children[] | boolean;
+
+  /**
+   * This part is nullish. Subsequent parts use conditional access operators.
+   */
+  nullish?: boolean;
+}
+/**
+ * A part of a member expression. Each part can provide one of the following
+ * props:
+ *
+ * * **id**: The identifier for the member expression part
+ * * **refkey**: A refkey for a symbol whose name becomes the identifier
+ * * **symbol**: a symbol whose name becomes the identifier part
+ * * **args**: create a method call with the given args
+ */
+MemberExpression.Part = function (props: MemberExpressionPartProps) {
+  /**
+   * This component does nothing except hold props which are retrieved by
+   * the `MemberExpression` component.
+   */
+};
diff --git a/packages/typescript/src/components/ObjectExpression.tsx b/packages/typescript/src/components/ObjectExpression.tsx
index e56d63a..db537be 100644
--- a/packages/typescript/src/components/ObjectExpression.tsx
+++ b/packages/typescript/src/components/ObjectExpression.tsx
@@ -92,7 +92,7 @@ export interface ObjectPropertyProps {
   value?: Children;
   jsValue?: unknown;
   children?: Children;
-  refkey?: Refkey;
+  refkey?: Refkey | Refkey[];
 }
 
 export function ObjectProperty(props: ObjectPropertyProps) {


Test patch:
diff --git a/packages/typescript/test/member-expression.test.tsx b/packages/typescript/test/member-expression.test.tsx
new file mode 100644
index 0000000..daf72b4
--- /dev/null
+++ b/packages/typescript/test/member-expression.test.tsx
@@ -0,0 +1,297 @@
+import { 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 { VarDeclaration } from "../src/components/VarDeclaration.jsx";
+import { toSourceText } from "./utils.js";
+
+it("renders basic member expression with dot notation", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" />
+        <MemberExpression.Part id="property" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    obj.property
+  `);
+});
+
+it("renders member expression with bracket notation for invalid identifiers", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" />
+        <MemberExpression.Part id="property-name" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    obj["property-name"]
+  `);
+});
+
+it("handles nullish chaining", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" nullish={true} />
+        <MemberExpression.Part id="property" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    obj?.property
+  `);
+});
+
+it("supports multiple levels of nesting", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="a" />
+        <MemberExpression.Part id="b" />
+        <MemberExpression.Part id="c" />
+        <MemberExpression.Part id="d" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    a.b.c.d
+  `);
+});
+
+it("ignores non-part children", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" />
+        <MemberExpression.Part id="property" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    obj.property
+  `);
+});
+
+it("flattens nested member expressions", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="outer" />
+        <MemberExpression>
+          <MemberExpression.Part id="inner" />
+          <MemberExpression.Part id="prop" />
+        </MemberExpression>
+        <MemberExpression.Part id="last" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    outer.inner.prop.last
+  `);
+});
+
+it("handles a mix of dot and bracket notation", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="obj" />
+        <MemberExpression.Part id="normalProp" />
+        <MemberExpression.Part id="special-prop" />
+        <MemberExpression.Part id="123" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    obj.normalProp["special-prop"]["123"]
+  `);
+});
+
+it("handles nullish chaining at multiple levels", () => {
+  expect(
+    toSourceText(
+      <MemberExpression>
+        <MemberExpression.Part id="a" />
+        <MemberExpression.Part id="b" nullish={true} />
+        <MemberExpression.Part id="c" />
+        <MemberExpression.Part id="d" nullish={true} />
+        <MemberExpression.Part id="e" />
+      </MemberExpression>,
+    ),
+  ).toBe(d`
+    a.b?.c.d?.e
+  `);
+});
+
+describe("with refkeys", () => {
+  it("handles symbols correctly", () => {
+    const rk1 = refkey();
+    const rk2 = refkey();
+    expect(
+      toSourceText(
+        <StatementList>
+          <VarDeclaration name="test1" refkey={rk1} initializer={1} />
+          <VarDeclaration name="test1" refkey={rk2} initializer={2} />
+          <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 late resolved refkeys correctly", () => {
+    const rk1 = refkey();
+    const rk2 = refkey();
+    expect(
+      toSourceText(
+        <StatementList>
+          <MemberExpression>
+            <MemberExpression.Part refkey={rk1} />
+            <MemberExpression.Part refkey={rk2} />
+          </MemberExpression>
+          <VarDeclaration name="test1" refkey={rk1} initializer={1} />
+          <VarDeclaration name="test1" refkey={rk2} initializer={2} />
+        </StatementList>,
+      ),
+    ).toBe(d`
+      test1.test1_2;
+      const test1 = 1;
+      const test1_2 = 2;
+    `);
+  });
+});
+
+describe("with function calls", () => {
+  it("handles simple function calls correctly", () => {
+    expect(
+      toSourceText(
+        <MemberExpression>
+          <MemberExpression.Part id="myFunction" />
+          <MemberExpression.Part args={[1, 2]} />
+        </MemberExpression>,
+      ),
+    ).toBe(d`
+      myFunction(1, 2)
+    `);
+  });
+
+  it("handles nullish function calls correctly", () => {
+    expect(
+      toSourceText(
+        <MemberExpression>
+          <MemberExpression.Part id="method1" nullish />
+          <MemberExpression.Part nullish args={[1, 2]} />
+          <MemberExpression.Part nullish args={[]} />
+          <MemberExpression.Part id="method2" nullish />
+          <MemberExpression.Part args={[]} />
+          <MemberExpression.Part id="prop" />
+        </MemberExpression>,
+      ),
+    ).toBe(d`
+      method1?.(1, 2)?.()?.method2?.().prop
+    `);
+  });
+
+  it("handles function calls returning nullish correctly", () => {
+    expect(
+      toSourceText(
+        <MemberExpression>
+          <MemberExpression.Part id="myFunction" />
+          <MemberExpression.Part args={[1, 2]} nullish />
+          <MemberExpression.Part id="prop" />
+        </MemberExpression>,
+      ),
+    ).toBe(d`
+      myFunction(1, 2)?.prop
+    `);
+  });
+});
+
+describe("formatting", () => {
+  describe("simple chains", () => {
+    it("just dots", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="four" />
+            <MemberExpression.Part id="four" />
+            <MemberExpression.Part id="four" />
+            <MemberExpression.Part id="four" />
+            <MemberExpression.Part id="four" />
+            <MemberExpression.Part id="four" />
+          </MemberExpression>,
+          { printWidth: 12 },
+        ),
+      ).toBe(d`
+        four.four
+          .four.four
+          .four.four
+      `);
+    });
+
+    it("bracket breaks", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="obj" />
+            <MemberExpression.Part id="property-name" />
+            <MemberExpression.Part id="prop" />
+          </MemberExpression>,
+          { printWidth: 12 },
+        ),
+      ).toBe(d`
+        obj[
+          "property-name"
+        ].prop
+      `);
+    });
+  });
+
+  describe("call chains", () => {
+    it("handles single calls", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="z" />
+            <MemberExpression.Part id="object" />
+            <MemberExpression.Part
+              args={[<ObjectExpression jsValue={{ x: 1 }} />]}
+            />
+          </MemberExpression>,
+          { printWidth: 12 },
+        ),
+      ).toBe(d`
+        z.object({
+          x: 1,
+        })
+      `);
+    });
+
+    it.skip("handles multiple calls", () => {
+      expect(
+        toSourceText(
+          <MemberExpression>
+            <MemberExpression.Part id="z" />
+            <MemberExpression.Part id="object" />
+            <MemberExpression.Part
+              args={[<ObjectExpression jsValue={{ x: 1 }} />]}
+            />
+            <MemberExpression.Part id="partial" />
+            <MemberExpression.Part args={[]} />
+          </MemberExpression>,
+          { printWidth: 12 },
+        ),
+      ).toBe(d`
+        z
+          .object({
+            x: 1,
+          })
+          .partial()
+      `);
+    });
+  });
+});