Repository: alloy-framework/alloy (ts)

Issue:
Support typeOnly references.
There should be a way to reference just a type such that any imports generated include the `type` modifier. It may be possible to do this automatically in type contexts, but there should still probably be a low-level way to do it e.g. via `<Reference refkey={} type />`.

Pull request description:
Type references
fix #120

when using a typescript reference can specify it is used in a type context which updates the imports to include `type` keyword. 

If a symbol is used both as type and non type the keyword will be omitted.
If all symbols from an import are types it uses this syntax `import type {A, B, C} from "..."` otherwise it use `import {type A, B, type C} from "..."`

Patch:
diff --git a/.chronus/changes/type-ref-2025-3-18-22-0-49.md b/.chronus/changes/type-ref-2025-3-18-22-0-49.md
new file mode 100644
index 0000000..8091a3c
--- /dev/null
+++ b/.chronus/changes/type-ref-2025-3-18-22-0-49.md
@@ -0,0 +1,8 @@
+---
+# Change versionKind to one of: breaking, feature, fix, internal
+changeKind: feature
+packages:
+  - "@alloy-js/typescript"
+---
+
+Add `type` prop on `Reference` component to mark this reference as a type only reference allowing imports to be specialized in type import
diff --git a/packages/docs/scripts/build-json.ts b/packages/docs/scripts/build-json.ts
index 8d56db8..82ae44c 100644
--- a/packages/docs/scripts/build-json.ts
+++ b/packages/docs/scripts/build-json.ts
@@ -238,11 +238,17 @@ function queryApis(apiModel: ApiModel): DocumentationStructure {
             if ((member as ApiFunction).parameters.length > 0) {
               const propsTypeRef = (member as ApiFunction).parameters[0]
                 .parameterTypeExcerpt.spannedTokens[0].canonicalReference;
-              const resolvedPropType = apiModel.resolveDeclarationReference(
+              const model = apiModel.resolveDeclarationReference(
                 propsTypeRef!,
                 undefined,
-              ).resolvedApiItem!;
+              );
 
+              const resolvedPropType = model.resolvedApiItem;
+              if (!resolvedPropType) {
+                throw new Error(
+                  `Cannot resolve prop type for ${member.displayName}: ${model.errorMessage}`,
+                );
+              }
               if (resolvedPropType.kind === ApiItemKind.Interface) {
                 componentPropTypes.push(resolvedPropType as ApiInterface);
                 propTypes.add(resolvedPropType as ApiInterface);
diff --git a/packages/typescript/src/components/ImportStatement.tsx b/packages/typescript/src/components/ImportStatement.tsx
index ac54648..508ca01 100644
--- a/packages/typescript/src/components/ImportStatement.tsx
+++ b/packages/typescript/src/components/ImportStatement.tsx
@@ -9,6 +9,7 @@ import {
   ImportedSymbol,
   ImportRecords,
   TSPackageScope,
+  TSSymbolFlags,
 } from "../symbols/index.js";
 import { modulePath } from "../utils.js";
 import { usePackage } from "./PackageDirectory.js";
@@ -90,11 +91,23 @@ export function ImportStatement(props: ImportStatementProps) {
     }
 
     if (namedImportSymbols.length > 0) {
+      const allNamedImportsAreTypes = namedImportSymbols.every(
+        (nis) => nis.target.tsFlags & TSSymbolFlags.TypeSymbol,
+      );
+
+      if (allNamedImportsAreTypes) {
+        parts.push("type ");
+      }
       parts.push("{ ");
       parts.push(
         mapJoin(
           () => namedImportSymbols,
-          (nis) => <ImportBinding importedSymbol={nis} />,
+          (nis) => (
+            <ImportBinding
+              importedSymbol={nis}
+              inTypeImport={allNamedImportsAreTypes}
+            />
+          ),
           { joiner: ", " },
         ),
       );
@@ -109,9 +122,10 @@ export function ImportStatement(props: ImportStatementProps) {
 interface ImportBindingProps {
   importedSymbol: ImportedSymbol;
   default?: boolean;
+  inTypeImport?: boolean;
 }
 
-function ImportBinding(props: ImportBindingProps) {
+function ImportBinding(props: Readonly<ImportBindingProps>) {
   const text = memo(() => {
     const localName = props.importedSymbol.local.name;
     const targetName = props.importedSymbol.target.name;
@@ -124,5 +138,18 @@ function ImportBinding(props: ImportBindingProps) {
     }
   });
 
-  return text;
+  const prefix = memo(() =>
+    (
+      !props.inTypeImport &&
+      props.importedSymbol.local.tsFlags & TSSymbolFlags.TypeSymbol
+    ) ?
+      "type "
+    : "",
+  );
+  return (
+    <>
+      {prefix}
+      {text}
+    </>
+  );
 }
diff --git a/packages/typescript/src/components/Reference.tsx b/packages/typescript/src/components/Reference.tsx
index 65f5e30..6d278c7 100644
--- a/packages/typescript/src/components/Reference.tsx
+++ b/packages/typescript/src/components/Reference.tsx
@@ -3,10 +3,18 @@ import { ref } from "../symbols/index.js";
 
 export interface ReferenceProps {
   refkey: Refkey;
+
+  /**
+   * Whether this is a reference to a type.
+   *
+   * @remarks
+   * This affect things like import where `type` keyword might be used.
+   */
+  type?: boolean;
 }
 
-export function Reference({ refkey }: ReferenceProps) {
-  const reference = ref(refkey);
+export function Reference({ refkey, type }: ReferenceProps) {
+  const reference = ref(refkey, { type });
 
   return <>{reference}</>;
 }
diff --git a/packages/typescript/src/symbols/reference.ts b/packages/typescript/src/symbols/reference.ts
index a30a5e0..5cf838e 100644
--- a/packages/typescript/src/symbols/reference.ts
+++ b/packages/typescript/src/symbols/reference.ts
@@ -20,7 +20,10 @@ import { TSModuleScope } from "./ts-module-scope.js";
 import { TSOutputSymbol, TSSymbolFlags } from "./ts-output-symbol.js";
 import { TSPackageScope } from "./ts-package-scope.js";
 
-export function ref(refkey: Refkey): () => string {
+export interface RefOptions {
+  type?: boolean;
+}
+export function ref(refkey: Refkey, options?: RefOptions): () => string {
   const sourceFile = useContext(SourceFileContext);
   const resolveResult = resolve<TSOutputScope, TSOutputSymbol>(
     refkey as Refkey,
@@ -103,8 +106,13 @@ export function ref(refkey: Refkey): () => string {
       ];
 
       const importSymbol = symbolPath[0];
+
       localSymbol = untrack(() =>
-        sourceFile!.scope.addImport(importSymbol, pathDown[0] as TSModuleScope),
+        sourceFile!.scope.addImport(
+          importSymbol,
+          pathDown[0] as TSModuleScope,
+          { type: options?.type },
+        ),
       );
     }
 
diff --git a/packages/typescript/src/symbols/ts-module-scope.ts b/packages/typescript/src/symbols/ts-module-scope.ts
index 4d2c7bc..8889de5 100644
--- a/packages/typescript/src/symbols/ts-module-scope.ts
+++ b/packages/typescript/src/symbols/ts-module-scope.ts
@@ -11,6 +11,10 @@ export interface ImportedSymbol {
 }
 export type ImportRecords = Map<TSModuleScope, Set<ImportedSymbol>>;
 
+export interface AddImportOptions {
+  type?: boolean;
+}
+
 export interface TSModuleScope extends OutputScope {
   kind: "module";
   exportedSymbols: Map<Refkey, TSOutputSymbol>;
@@ -19,7 +23,11 @@ export interface TSModuleScope extends OutputScope {
    */
   importedSymbols: Map<TSOutputSymbol, TSOutputSymbol>;
   importedModules: ImportRecords;
-  addImport(symbol: TSOutputSymbol, module: TSModuleScope): TSOutputSymbol;
+  addImport(
+    symbol: TSOutputSymbol,
+    module: TSModuleScope,
+    options?: AddImportOptions,
+  ): TSOutputSymbol;
 }
 
 export function createTSModuleScope(
@@ -34,9 +42,13 @@ export function createTSModuleScope(
     exportedSymbols: new Map(),
     importedSymbols: new Map(),
     importedModules: new Map(),
-    addImport(this: TSModuleScope, targetSymbol, targetModule) {
-      if (this.importedSymbols.has(targetSymbol)) {
-        return this.importedSymbols.get(targetSymbol)!;
+    addImport(this: TSModuleScope, targetSymbol, targetModule, options) {
+      const existing = this.importedSymbols.get(targetSymbol);
+      if (existing) {
+        if (!options?.type && existing.tsFlags & TSSymbolFlags.TypeSymbol) {
+          existing.tsFlags &= ~TSSymbolFlags.TypeSymbol;
+        }
+        return existing;
       }
 
       if (targetModule.kind !== "module") {
@@ -54,6 +66,9 @@ export function createTSModuleScope(
         refkey: refkey({}),
         tsFlags: TSSymbolFlags.LocalImportSymbol,
       });
+      if (options?.type) {
+        localSymbol.tsFlags |= TSSymbolFlags.TypeSymbol;
+      }
 
       this.importedSymbols.set(targetSymbol, localSymbol);
       this.importedModules.get(targetModule)!.add({


Test patch:
diff --git a/packages/typescript/test/imports.test.tsx b/packages/typescript/test/imports.test.tsx
index 0b5aec0..44f27df 100644
--- a/packages/typescript/test/imports.test.tsx
+++ b/packages/typescript/test/imports.test.tsx
@@ -1,4 +1,5 @@
 import {
+  List,
   Output,
   SourceDirectory,
   StatementList,
@@ -6,7 +7,7 @@ import {
   render,
 } from "@alloy-js/core";
 import "@alloy-js/core/testing";
-import { it } from "vitest";
+import { describe, it } from "vitest";
 import * as ts from "../src/components/index.js";
 import { Reference } from "../src/components/Reference.js";
 import { tsNameConflictResolver } from "../src/name-conflict-resolver.js";
@@ -308,3 +309,135 @@ it("handles conflicts with local declarations", () => {
     `,
   });
 });
+
+describe("type imports", () => {
+  function mkTestFile(name: string) {
+    const TypeA = refkey("TypeA");
+    const TypeB = refkey("TypeB");
+    const ClassA = refkey("ClassA");
+
+    return {
+      component: (
+        <ts.SourceFile path="test1.ts">
+          <ts.InterfaceDeclaration export name="TypeA" refkey={TypeA} />
+          <ts.InterfaceDeclaration export name="TypeB" refkey={TypeB} />
+          <ts.ClassDeclaration export name="ClassA" refkey={ClassA} />
+        </ts.SourceFile>
+      ),
+      TypeA,
+      TypeB,
+      ClassA,
+    };
+  }
+
+  it("adds type keyword only to type imports", () => {
+    const { component, TypeA, ClassA } = mkTestFile("test1.ts");
+    const res = render(
+      <Output>
+        {component}
+        <ts.SourceFile path="test2.ts">
+          <List>
+            <>
+              type A = <Reference refkey={TypeA} type />
+            </>
+            <>
+              class B extends <Reference refkey={ClassA} /> {"{}"}
+            </>
+          </List>
+        </ts.SourceFile>
+      </Output>,
+    );
+
+    assertFileContents(res, {
+      "test2.ts": `
+      import { type TypeA, ClassA } from "./test1.js";
+
+      type A = TypeA
+      class B extends ClassA {}
+    `,
+    });
+  });
+
+  it("add type keyword for whole import if all are types", () => {
+    const { component, TypeA, TypeB } = mkTestFile("test1.ts");
+    const res = render(
+      <Output>
+        {component}
+        <ts.SourceFile path="test2.ts">
+          <List>
+            <>
+              type A = <Reference refkey={TypeA} type />
+            </>
+            <>
+              type B = <Reference refkey={TypeB} type />
+            </>
+          </List>
+        </ts.SourceFile>
+      </Output>,
+    );
+
+    assertFileContents(res, {
+      "test2.ts": `
+      import type { TypeA, TypeB } from "./test1.js";
+
+      type A = TypeA
+      type B = TypeB
+    `,
+    });
+  });
+  it("reference same type multiple times", () => {
+    const { component, TypeA } = mkTestFile("test1.ts");
+    const res = render(
+      <Output>
+        {component}
+        <ts.SourceFile path="test2.ts">
+          <List>
+            <>
+              type A = <Reference refkey={TypeA} type />
+            </>
+            <>
+              type B = <Reference refkey={TypeA} type />
+            </>
+          </List>
+        </ts.SourceFile>
+      </Output>,
+    );
+
+    assertFileContents(res, {
+      "test2.ts": `
+      import type { TypeA } from "./test1.js";
+
+      type A = TypeA
+      type B = TypeA
+    `,
+    });
+  });
+
+  it("same reference used as both type and non type doesn't include type", () => {
+    const { component, ClassA } = mkTestFile("test1.ts");
+    const res = render(
+      <Output>
+        {component}
+        <ts.SourceFile path="test2.ts">
+          <List>
+            <>
+              type A = <Reference refkey={ClassA} type />
+            </>
+            <>
+              class B extends <Reference refkey={ClassA} /> {"{}"}
+            </>
+          </List>
+        </ts.SourceFile>
+      </Output>,
+    );
+
+    assertFileContents(res, {
+      "test2.ts": `
+      import { ClassA } from "./test1.js";
+
+      type A = ClassA
+      class B extends ClassA {}
+    `,
+    });
+  });
+});