Repository: alloy-framework/alloy
Pull request #26 (2024-09-19 04:24:33)

Support for member symbols, move some typescript components to core
I am still testing various E2Es and have some cleanups, will merge when done


Patch:
diff --git a/eslint.config.js b/eslint.config.js
index f840d26..28c2ed4 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,7 @@ export default tsEslint.config(
       "packages/babel-plugin-alloy/**/*",
       "packages/babel-preset-alloy/**/*",
       "packages/docs/**/*",
+      "samples/**/*", // for some reason eslint is unhappy with some files in here
     ],
   },
   eslint.configs.recommended,
diff --git a/packages/core/package.json b/packages/core/package.json
index 610541a..f28ccca 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -19,6 +19,10 @@
     "./stc": {
       "development": "./src/components/stc/index.ts",
       "import": "./dist/src/components/stc/index.js"
+    },
+    "./components": {
+      "development": "./src/components/index.ts",
+      "import": "./dist/src/components/index.js"
     }
   },
   "scripts": {
diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts
index 59e627c..315b886 100644
--- a/packages/core/src/binder.ts
+++ b/packages/core/src/binder.ts
@@ -1,24 +1,224 @@
 import { memo } from "@alloy-js/core/jsx-runtime";
-import { computed, reactive, Ref, shallowRef } from "@vue/reactivity";
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import {
+  computed,
+  effect,
+  reactive,
+  Ref,
+  ShallowRef,
+  shallowRef,
+} from "@vue/reactivity";
+import { useMemberScope } from "./context/member-scope.js";
 import { useScope } from "./context/scope.js";
-import { Refkey } from "./refkey.js";
+import { refkey, Refkey } from "./refkey.js";
 export type Metadata = object;
 
+/**
+ * Flags that describe an output symbol.
+ */
+export enum OutputSymbolFlags {
+  None = 0,
+
+  /**
+   * The symbol is an instance member container. Symbols with this flag will have a
+   * instanceMemberScope property that contains symbols for instance members.
+   */
+  InstanceMemberContainer = 1 << 0,
+
+  /**
+   * The symbol is a static member container. Symbols with this flag will have a
+   * staticMemberScope property that contains symbols for static members.
+   */
+  StaticMemberContainer = 1 << 1,
+
+  /**
+   * Whether this symbol contains members of any kind.
+   */
+  MemberContainer = InstanceMemberContainer | StaticMemberContainer,
+
+  /**
+   * Whether this symbol is an instance member of another symbol (i.e that it is
+   * stored in an instance member scope).
+   */
+  InstanceMember = 1 << 2,
+
+  /**
+   * Whether this symbol is a static member of another symbol (i.e that it is
+   * stored in a static member scope).
+   */
+  StaticMember = 1 << 3,
+
+  /**
+   * Whether this is an instance member or static member of another symbol.
+   */
+  Member = InstanceMember | StaticMember,
+}
+
+/**
+ * An output symbol is a named entity that can be referenced in your output
+ * code. Referencing symbols is generally accomplished by using a
+ * {@link Refkey}.
+ *
+ * @remarks
+ *
+ * This interface is the base implementation of symbol. Generally, most
+ * languages will have subtypes of this interface that provide additional,
+ * language-specific metadata about declared symbols. For example, symbols in
+ * TypeScript need to track whether they are exported or what kind of symbol
+ * they are (e.g. type, value, parameter, etc.)
+ *
+ * Symbols are reactive values, which allows you to observe changes to the symbol
+ * information. For example, a symbol's name may change if it becomes conflicted
+ * with a symbol that is added later.
+ */
 export interface OutputSymbol {
+  /**
+   * The original name of the symbol, before any conflicts were resolved.
+   */
   originalName: string;
+
+  /**
+   * The name of the symbol.
+   */
   name: string;
+
+  /**
+   * The symbol's flags.
+   */
+  flags: OutputSymbolFlags;
+
+  /**
+   * The scope in which the symbol is defined.
+   */
   scope: OutputScope;
+
+  /**
+   * The binder instance that created this symbol.
+   */
+  binder: Binder;
+
+  /**
+   * A unique value that references this symbol.
+   */
   refkey: Refkey;
+
+  /**
+   * The instance members available on this symbol.
+   *
+   * @remarks
+   *
+   * Instance members are members that are available when this symbol is
+   * instantiated. Instantiation is language-specific. For example, in
+   * TypeScript, instance members of symbols for classes are available when the
+   * class is instantiated with a new expression.
+   *
+   * When a symbol cannot have instance members, this is undefined.
+   */
+  instanceMemberScope?: OutputScope;
+
+  /**
+   * The static members available on this symbol.
+   *
+   * @remarks
+   *
+   * Static members are members that are available on the symbol itself. These symbols
+   * can be accessed off the parent symbol directly with a refkey. There is only ever
+   * one static member symbol in the output (i.e., the symbol is unique).
+   */
+  staticMemberScope?: OutputScope;
 }
 
+/**
+ * Flags that describe an output scope.
+ */
+export enum OutputScopeFlags {
+  None = 0,
+
+  /**
+   * This scope is a static member scope.
+   */
+  StaticMemberScope = 1 << 0,
+
+  /**
+   * This scope is an instance member scope.
+   */
+  InstanceMemberScope = 1 << 1,
+
+  /**
+   * This scope is a member scope. Scopes with this flag will have an `owner`
+   * property that points to the symbol whose member this scope holds.
+   */
+  MemberScope = StaticMemberScope | InstanceMemberScope,
+}
+
+/**
+ * A container of symbols accessible within some scope in your output code.
+ *
+ * @remarks
+ *
+ * Scopes form a tree. All scopes except the global scope have a parent scope
+ * identified by `parent`. Scopes can have nested scopes which can be accessed
+ * using the `children` property. Whether a child scope can be accessed from
+ * the parent scope is determined by each language's scoping rules.
+ *
+ * This interface is the base implementation of scope. Generally, most languages
+ * will have subtypes of this interface that provide additional metadata about
+ * the scope. For example, TypeScript scopes need to track whether the scope
+ * represents a package, module, class, function, etc.
+ *
+ * Scopes are reactive values, which allows you to observe changes to the scope
+ * within a reactive context.
+ */
 export interface OutputScope {
+  /**
+   * The kind of scope. Subtypes will likely provide a set of known scope kinds.
+   * The kind is not used by the binder itself.
+   */
   kind: string;
+
+  /**
+   * The name of the scope.
+   */
   name: string;
+
+  /**
+   * The flags that describe this scope.
+   */
+  flags: OutputScopeFlags;
+
+  /**
+   * The symbols defined within this scope.
+   */
   symbols: Set<OutputSymbol>;
+
+  /**
+   * The symbols defined within this scope, indexed by refkey.
+   */
+  symbolsByRefkey: Map<Refkey, OutputSymbol>;
+
+  /**
+   * The scopes nested within this scope.
+   */
   children: Set<OutputScope>;
+
+  /**
+   * The container of this scope.
+   */
   parent: OutputScope | undefined;
+
+  /**
+   * The symbol that owns this scope. This is only defined for scopes that have
+   * the {@link OutputScopeFlags.StaticMemberScope} flag.
+   */
+  owner?: OutputSymbol;
+
+  /**
+   * The binder that created this scope.
+   */
   binder: Binder;
+
+  /**
+   * Get the names of all symbols in this scope.
+   */
   getSymbolNames(): Set<string>;
 }
 
@@ -26,43 +226,111 @@ export interface OutputScope {
  * The binder tracks all output scopes and symbols. Scopes are nested containers
  * for symbols.
  *
+ * @remarks
+ *
  * Symbol information is reactive because in certain situations this data may
  * change. For example, when a symbol becomes conflicted with another symbol,
  * one of the symbol names may change. Ensure that you interact with binder
  * values in a reactive context (i.e. within JSX/code template, or within
  * memo/computed/etc).
  *
- * The binder interacts with node context by pulling the current scope out of the
- *
  */
 export interface Binder {
+  /**
+   * Create a new scope. The scope will be added to the parent scope's children.
+   * The returned scope object is reactive.
+   */
   createScope<T extends OutputScope>(
     args: {
       kind: T["kind"];
       name: string;
       parent?: OutputScope | undefined;
+      flags?: OutputScopeFlags;
+      owner?: OutputSymbol;
     } & Omit<T, keyof OutputScope>,
   ): T;
 
+  /**
+   * Create a new symbol. The symbol will be added to the parent scope's symbols.
+   * The returned symbol object is reactive.
+   */
   createSymbol<T extends OutputSymbol>(
     args: {
       name: string;
       scope?: OutputScope;
       refkey?: unknown;
+      flags?: OutputSymbolFlags;
     } & Omit<T, keyof OutputSymbol>,
   ): T;
 
+  /**
+   * Instantiate the static members of a symbol into the instance members of
+   * another symbol.
+   *
+   * @param sourceSym - The symbol to add instance members to.
+   * @param targetSym - The symbol with the static members to instantiate.
+   */
+  instantiateSymbolInto(sourceSym: OutputSymbol, targetSym: OutputSymbol): void;
+
+  /**
+   * Add static members to an existing symbol.
+   *
+   * @param symbol - The symbol to add static members to.
+   */
+  addStaticMembersToSymbol(symbol: OutputSymbol): void;
+
+  /**
+   * Add instance members to an existing symbol.
+   *
+   * @param symbol - The symbol to add instance members to.
+   */
+  addInstanceMembersToSymbol(symbol: OutputSymbol): void;
+
+  /**
+   * Resolve the given refkey in the current scope.
+   *
+   * @returns a ref for the resolution result.
+   * @see {@link resolve} a convenience function that uses the current scope and
+   * binder.
+   */
   resolveDeclarationByKey<
     TScope extends OutputScope = OutputScope,
     TSymbol extends OutputSymbol = OutputSymbol,
   >(
-    currentScope: OutputScope,
-    key: unknown,
+    currentScope: TScope | undefined,
+    currentMemberScope: TScope | undefined,
+    key: Refkey,
   ): Ref<ResolutionResult<TScope, TSymbol> | undefined>;
 
   globalScope: OutputScope;
 }
 
+/**
+ * A successful resolution of a refkey.
+ *
+ * @example
+ *
+ * Let's say we have the following scopes and symbols:
+ *
+ * ```
+ * scope: global scope
+ * ├── scope: namespace scope 1
+ * │   └── symbol: foo
+ * │       └── static member scope
+ * │           └── symbol: bar
+ * └── scope: namespace scope 2
+ *    └── (resolve bar from here)
+ * ```
+ *
+ * When we resolve the refkey for `bar` from within `namespace scope 2`, we will get the following
+ * resolution result:
+ *
+ * * **targetDeclaration**: symbol bar, the symbol we resolved.
+ * * **commonScope**: global scope, because this is the most specific scope that contains both the declaration and the reference.
+ * * **pathUp**: [namespace scope 2], because this is the scope between the reference and the common scope.
+ * * **pathDown**: [namespace scope 1], because this is the scope between the common scope and the declaration
+ * * **memberPath**: [foo, bar], because we resolved a member symbol and these are the symbols that lead from the base declaration to the member symbol.
+ */
 export interface ResolutionResult<
   TScope extends OutputScope,
   TSymbol extends OutputSymbol,
@@ -85,6 +353,12 @@ export interface ResolutionResult<
    * The scope which contains both the reference and the declaration.
    */
   commonScope: TScope | undefined;
+
+  /**
+   * When resolving a member symbol, this is the path of symbols that lead from
+   * the base declaration to the member symbol.
+   */
+  memberPath?: TSymbol[];
 }
 
 export interface NameConflictResolver {
@@ -100,6 +374,9 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     createScope,
     createSymbol,
     resolveDeclarationByKey,
+    addStaticMembersToSymbol,
+    addInstanceMembersToSymbol,
+    instantiateSymbolInto,
     globalScope: undefined as any,
   };
 
@@ -109,14 +386,16 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     kind: "global",
     name: "<global>",
     symbols: new Set(),
+    symbolsByRefkey: new Map(),
     children: new Set(),
     parent: undefined,
     binder,
+    flags: OutputScopeFlags.None,
     getSymbolNames: symbolNames(globalSymbols),
   });
 
-  const knownDeclarations = new Map<unknown, OutputSymbol>();
-  const waitingDeclarations = new Map<unknown, Ref<OutputSymbol | undefined>>();
+  const knownDeclarations = new Map<Refkey, OutputSymbol>();
+  const waitingDeclarations = new Map<Refkey, Ref<OutputSymbol | undefined>>();
 
   return binder;
 
@@ -125,24 +404,55 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       kind: string;
       name: string;
       parent?: OutputScope;
+      flags?: OutputScopeFlags;
+      owner?: OutputSymbol;
     } & Omit<T, keyof OutputScope>,
   ): T {
-    const { kind, name, parent, ...rest } = args;
+    const {
+      kind,
+      name,
+      parent,
+      owner,
+      flags = OutputScopeFlags.None,
+      ...rest
+    } = args;
+
+    if (flags & OutputScopeFlags.MemberScope) {
+      if (!owner) {
+        throw new Error("Member scopes must have an owner");
+      }
+    }
+
+    // member scopes don't have a parent regular scope. Perhaps in the future
+    // their parent would be something like a prototype object.
+    let parentScope: OutputScope | undefined;
+    if (parent) {
+      parentScope = parent;
+    } else if (flags & OutputScopeFlags.MemberScope) {
+      parentScope = undefined;
+    } else {
+      parentScope = useScope() ?? binder.globalScope;
+    }
 
-    const parentScope = parent ?? useScope() ?? binder.globalScope;
     const symbols = reactive(new Set<OutputSymbol>());
+    const symbolsByRefkey = reactive(new Map<Refkey, OutputSymbol>());
     const scope: T = reactive({
       kind: kind,
       name: name,
       symbols,
+      symbolsByRefkey,
       children: new Set(),
       parent: parentScope,
+      flags,
+      owner,
       binder,
       ...rest,
       getSymbolNames: symbolNames(symbols),
     }) as T;
 
-    parentScope.children.add(scope);
+    if (parentScope) {
+      parentScope.children.add(scope);
+    }
 
     return scope as T;
   }
@@ -151,94 +461,247 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
     args: {
       name: string;
       scope?: OutputScope;
-      refkey?: unknown;
+      refkey?: Refkey;
+      flags?: OutputSymbolFlags;
     } & Omit<T, keyof OutputSymbol>,
   ): T {
-    const { name, scope, refkey, ...rest } = args;
+    const {
+      name,
+      scope = useDefaultScope(args.flags),
+      refkey,
+      flags = OutputSymbolFlags.None,
+      ...rest
+    } = args;
+
+    if (!scope) {
+      throw new Error(
+        "No scope was provided and no scope could be found in context",
+      );
+    }
+
+    if (
+      flags & OutputSymbolFlags.Member &&
+      (scope.flags & OutputScopeFlags.MemberScope) === 0
+    ) {
+      throw new Error("Member symbols must be stored in a member scope.");
+    }
+
+    if (scope.flags & OutputScopeFlags.StaticMemberScope) {
+      if (
+        ~flags & OutputSymbolFlags.InstanceMember &&
+        ~flags & OutputSymbolFlags.StaticMember
+      ) {
+        throw new Error(
+          "Symbols stored in a member scope must have either OutputSymbolFlags.InstanceMember or OutputSymbolFlags.StaticMember flags",
+        );
+      }
+    }
 
-    const parentScope = scope ?? useScope() ?? binder.globalScope;
     const symbol = reactive({
       originalName: name,
       name: name,
-      scope: parentScope,
+      scope,
       refkey,
+      binder,
+      flags,
       ...rest,
     }) as T;
-    parentScope.symbols.add(symbol);
-    deconflict(symbol);
 
-    knownDeclarations.set(refkey, symbol);
-    if (waitingDeclarations.has(refkey)) {
-      const signal = waitingDeclarations.get(refkey)!;
-      signal.value = symbol;
+    if (args.flags && args.flags & OutputSymbolFlags.InstanceMemberContainer) {
+      symbol.instanceMemberScope = createScope({
+        kind: "member",
+        name: "instance members",
+        parent: undefined,
+        owner: symbol,
+        flags: OutputScopeFlags.InstanceMemberScope,
+      });
     }
+
+    if (args.flags && args.flags & OutputSymbolFlags.StaticMemberContainer) {
+      symbol.staticMemberScope = createScope({
+        kind: "member",
+        name: "static members",
+        parent: undefined,
+        owner: symbol,
+        flags: OutputScopeFlags.StaticMemberScope,
+      });
+    }
+
+    scope.symbols.add(symbol);
+    scope.symbolsByRefkey.set(symbol.refkey, symbol);
+
+    deconflict(symbol);
+    notifyRefkey(refkey, symbol);
+
     return symbol;
   }
 
+  function instantiateSymbolInto(source: OutputSymbol, target: OutputSymbol) {
+    if (~source.flags & OutputSymbolFlags.InstanceMemberContainer) {
+      throw new Error("Can only instantiate symbols with instance members");
+    }
+
+    addInstanceMembersToSymbol(target);
+
+    effect(() => {
+      for (const sym of source.instanceMemberScope!.symbols) {
+        if (target.instanceMemberScope!.symbols.has(sym)) {
+          continue;
+        }
+
+        createSymbol({
+          name: sym.name,
+          scope: target.instanceMemberScope!,
+          refkey: refkey(target.refkey, sym.refkey),
+          flags: sym.flags | OutputSymbolFlags.InstanceMember,
+        });
+      }
+    });
+  }
+
+  function addStaticMembersToSymbol(symbol: OutputSymbol) {
+    if (symbol.flags & OutputSymbolFlags.StaticMemberContainer) {
+      // nothing to do.
+      return;
+    }
+
+    symbol.flags |= OutputSymbolFlags.StaticMemberContainer;
+    symbol.staticMemberScope = createScope({
+      kind: "member",
+      name: "static members",
+      parent: undefined,
+      owner: symbol,
+      flags: OutputScopeFlags.StaticMemberScope,
+    });
+  }
+
+  function addInstanceMembersToSymbol(symbol: OutputSymbol) {
+    if (symbol.flags & OutputSymbolFlags.InstanceMemberContainer) {
+      // nothing to do.
+      return;
+    }
+
+    symbol.flags |= OutputSymbolFlags.InstanceMemberContainer;
+    symbol.instanceMemberScope = createScope({
+      kind: "member",
+      name: "instance members",
+      parent: undefined,
+      owner: symbol,
+      flags: OutputScopeFlags.InstanceMemberScope,
+    });
+  }
+
   function resolveDeclarationByKey<
     TScope extends OutputScope = OutputScope,
     TSymbol extends OutputSymbol = OutputSymbol,
   >(
-    currentScope: OutputScope,
-    key: unknown,
+    currentScope: TScope | undefined,
+    currentMemberScope: TScope | undefined,
+    key: Refkey,
   ): Ref<ResolutionResult<TScope, TSymbol> | undefined> {
     const targetDeclaration = knownDeclarations.get(key);
-    let declSignal: Ref<TSymbol | undefined>;
 
     if (targetDeclaration) {
       // this any cast hides a ridiculous error, fix it probably
-      declSignal = shallowRef(targetDeclaration) as any;
+      return shallowRef(
+        buildResult(currentScope, currentMemberScope, targetDeclaration),
+      ) as any;
     } else {
-      if (waitingDeclarations.has(key)) {
-        declSignal = waitingDeclarations.get(key)! as Ref<TSymbol>;
-      } else {
-        declSignal = shallowRef();
-        waitingDeclarations.set(key, declSignal);
-      }
+      return waitForRefkey(key, (symbol) => {
+        if (symbol) {
+          return buildResult(currentScope, currentMemberScope, symbol);
+        } else {
+          return undefined;
+        }
+      });
     }
+  }
 
-    return computed(() => {
-      if (declSignal.value !== undefined) {
-        return buildResult(declSignal.value);
-      } else {
-        return undefined;
-      }
-    });
-
-    function buildResult<
-      TScope extends OutputScope = OutputScope,
-      TSymbol extends OutputSymbol = OutputSymbol,
-    >(targetDeclaration: TSymbol): ResolutionResult<TScope, TSymbol> {
-      const targetScope = targetDeclaration.scope;
-      const targetChain = scopeChain(targetScope);
-      const currentChain = scopeChain(currentScope);
-      let diffStart = 0;
-      while (
-        targetChain[diffStart] &&
-        currentChain[diffStart] &&
-        targetChain[diffStart] === currentChain[diffStart]
-      ) {
-        diffStart++;
+  function buildResult<
+    TScope extends OutputScope = OutputScope,
+    TSymbol extends OutputSymbol = OutputSymbol,
+  >(
+    currentScope: TScope | undefined,
+    currentMemberScope: TScope | undefined,
+    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",
+        );
       }
 
-      const pathUp = currentChain.slice(diffStart);
-      const pathDown = targetChain.slice(diffStart);
-      const commonScope = targetChain[diffStart - 1] ?? null;
-
+      // todo: handle referencing nested objects by refkey
       return {
-        pathUp: pathUp as TScope[],
-        pathDown: pathDown as TScope[],
-        commonScope: commonScope as TScope,
-        targetDeclaration,
+        pathUp: [],
+        pathDown: [],
+        memberPath: [targetDeclarationBase],
+        commonScope: currentMemberScope,
+        targetDeclaration: targetDeclarationBase,
       };
     }
+
+    const { memberPath, scopeChain: targetChain } = scopeAndMemberChain<
+      TScope,
+      TSymbol
+    >(targetDeclarationBase);
+    const currentChain = scopeChain(currentScope);
+    let diffStart = 0;
+    while (
+      targetChain[diffStart] &&
+      currentChain[diffStart] &&
+      targetChain[diffStart] === currentChain[diffStart]
+    ) {
+      diffStart++;
+    }
+
+    const pathUp = currentChain.slice(diffStart);
+    const pathDown = targetChain.slice(diffStart);
+    const commonScope = targetChain[diffStart - 1] ?? null;
+
+    return {
+      pathUp,
+      pathDown,
+      memberPath,
+      commonScope,
+      targetDeclaration:
+        memberPath && memberPath.length > 0 ?
+          memberPath.at(0)!
+        : targetDeclarationBase,
+    };
+  }
+
+  function scopeAndMemberChain<
+    TScope extends OutputScope,
+    TSymbol extends OutputSymbol,
+  >(symbol: TSymbol) {
+    const result = {
+      memberPath: [] as TSymbol[],
+      scopeChain: [] as TScope[],
+    };
+
+    let currentSymbol = symbol;
+    while (currentSymbol.flags & OutputSymbolFlags.StaticMember) {
+      result.memberPath.unshift(currentSymbol);
+      currentSymbol = currentSymbol.scope.owner! as TSymbol;
+    }
+
+    if (symbol.flags & OutputSymbolFlags.StaticMember) {
+      result.memberPath.unshift(currentSymbol);
+    }
+
+    const startScope = currentSymbol.scope as TScope;
+    result.scopeChain = scopeChain(startScope);
+
+    return result;
   }
 
-  function scopeChain(scope: OutputScope | undefined) {
+  function scopeChain<TScope extends OutputScope>(scope: TScope | undefined) {
     const chain = [];
     while (scope) {
       chain.unshift(scope);
-      scope = scope.parent;
+      scope = scope.parent as TScope;
     }
 
     return chain;
@@ -263,20 +726,63 @@ export function createOutputBinder(options: BinderOptions = {}): Binder {
       }
     }
   }
+
+  function waitForRefkey<
+    TScope extends OutputScope,
+    TSymbol extends OutputSymbol,
+  >(
+    refkey: Refkey,
+    cb: (
+      symbol: TSymbol | undefined,
+    ) => ResolutionResult<TScope, TSymbol> | undefined,
+  ): ShallowRef<ResolutionResult<TScope, TSymbol> | undefined> {
+    let declSignal;
+    if (waitingDeclarations.has(refkey)) {
+      declSignal = waitingDeclarations.get(refkey)! as ShallowRef<TSymbol>;
+    } else {
+      declSignal = shallowRef();
+      waitingDeclarations.set(refkey, declSignal);
+    }
+
+    return computed(() => cb(declSignal.value));
+  }
+
+  function notifyRefkey(
+    refkey: Refkey | undefined,
+    symbol: OutputSymbol,
+  ): void {
+    if (!refkey) return;
+    knownDeclarations.set(refkey, symbol);
+    if (waitingDeclarations.has(refkey)) {
+      const signal = waitingDeclarations.get(refkey)!;
+      signal.value = symbol;
+    }
+  }
 }
 
 /**
  * Resolve a refkey in the current scope. Returns a Ref for the resolution result.
  * The value of the ref will be undefined if the identifier hasn't been resolved yet.
+ *
+ * @remarks
+ *
+ * This API may return a ref for undefined, but that does not mean that the symbol is
+ * not found. The symbol you're looking for may not have been declared yet. When the symbol
+ * is declared, the ref will be updated with the resolution result.
  */
 export function resolve<
   TScope extends OutputScope,
   TSymbol extends OutputSymbol,
 >(refkey: Refkey): Ref<ResolutionResult<TScope, TSymbol>> {
   const scope = useScope();
+  const memberScope = useMemberScope();
   const binder = scope.binder;
 
-  return binder.resolveDeclarationByKey(scope, refkey) as any;
+  return binder.resolveDeclarationByKey(
+    scope,
+    memberScope?.instanceMembers,
+    refkey,
+  ) as any;
 }
 
 const createSymbolsSymbol: unique symbol = Symbol();
@@ -304,3 +810,29 @@ function symbolNames(symbols: Set<OutputSymbol>): () => Set<string> {
     return names;
   });
 }
+
+/**
+ * Use symbol flags to determine the scope in which a symbol with those flags
+ * should be declared given the current context.
+ *
+ * @param flags - The symbol flags to use to determine the default scope.
+ * @returns an {@link OutputScope} that is the default scope for the given
+ * flags.
+ */
+export function useDefaultScope(
+  flags: OutputSymbolFlags = OutputSymbolFlags.None,
+) {
+  if ((flags & OutputSymbolFlags.Member) === 0) {
+    return useScope();
+  } else {
+    const memberScope = useMemberScope();
+    if (!memberScope) {
+      throw new Error("Cannot declare member symbols without a member scope");
+    }
+    if (flags & OutputSymbolFlags.InstanceMember) {
+      return memberScope.instanceMembers;
+    } else {
+      return memberScope.staticMembers;
+    }
+  }
+}
diff --git a/packages/core/src/components/Declaration.tsx b/packages/core/src/components/Declaration.tsx
index 2cce061..3f6640a 100644
--- a/packages/core/src/components/Declaration.tsx
+++ b/packages/core/src/components/Declaration.tsx
@@ -17,14 +17,16 @@ export interface DeclarationProps {
  *
  * @remarks
  *
- * This component must be called in one of two ways: with a name and an optional refkey,
- * or else passing in the symbol. When called with a name and refkey, a symbol will be
- * created in the current scope with that name and refkey. If a refkey is not provided,
+ * This component must be called in one of two ways: with a name and an optional
+ * refkey, or else passing in the symbol. When called with a name and refkey, a
+ * symbol will be created in the current scope (provided by
+ * {@link ScopeContext}) with that name and refkey. If a refkey is not provided,
  * `refkey(props.name)` is used.
  *
- * When called with a symbol, that symbol is merely exposed via {@link DeclarationContext }. It
- * is assumed that the caller of this component has created the symbol with the `createSymbol` API
- * on the {@link BinderContext }.
+ * When called with a symbol, that symbol is merely exposed via
+ * {@link DeclarationContext }. It is assumed that the caller of this component
+ * has created the symbol with the `createSymbol` API on the
+ * {@link BinderContext }.
  *
  * @see {@link BinderContext}
  */
diff --git a/packages/core/src/components/MemberDeclaration.tsx b/packages/core/src/components/MemberDeclaration.tsx
new file mode 100644
index 0000000..94143ca
--- /dev/null
+++ b/packages/core/src/components/MemberDeclaration.tsx
@@ -0,0 +1,62 @@
+import { Children } from "@alloy-js/core/jsx-runtime";
+import { OutputSymbol, OutputSymbolFlags } from "../binder.js";
+import { useContext } from "../context.js";
+import { BinderContext } from "../context/binder.js";
+import { MemberDeclarationContext } from "../context/member-declaration.js";
+import { Refkey, refkey } from "../refkey.js";
+
+export interface MemberDeclarationProps {
+  name?: string;
+  refkey?: Refkey;
+  symbol?: OutputSymbol;
+  children?: Children;
+  static?: boolean;
+}
+
+/**
+ * Declares a symbol in the current member scope for this component's children.
+ *
+ * @remarks
+ *
+ * This component must be called in one of two ways: with a name and an optional
+ * refkey, or else passing in the symbol. When called with a name and refkey, a
+ * symbol will be created in the current scope (provided by
+ * {@link MemberDeclarationContext}) with that name and refkey. If a refkey is not
+ * provided, `refkey(props.name)` is used.
+ *
+ * When called with a symbol, that symbol is merely exposed via
+ * {@link MemberDeclarationContext}. It is assumed that the caller of this component
+ * has created the symbol with the `createSymbol` API on the
+ * {@link BinderContext}.
+ *
+ * @see {@link BinderContext}
+ */
+export function MemberDeclaration(props: MemberDeclarationProps) {
+  const binder = useContext(BinderContext);
+  if (!binder) {
+    throw new Error("Need binder context to create declarations");
+  }
+
+  let declaration;
+  if (props.symbol) {
+    declaration = props.symbol;
+  } else {
+    if (!props.name) {
+      throw new Error(
+        "Must provide a member name, or else provide a member symbol",
+      );
+    }
+    const rk = props.refkey ? props.refkey : refkey(props.name);
+    declaration = binder.createSymbol({
+      name: props.name!,
+      refkey: rk,
+      flags: props.static ?
+        OutputSymbolFlags.StaticMember
+      : OutputSymbolFlags.InstanceMember,
+    });
+  }
+
+  return <MemberDeclarationContext.Provider value={declaration}>
+    {props.children}
+  </MemberDeclarationContext.Provider>;
+}
diff --git a/packages/core/src/components/MemberName.tsx b/packages/core/src/components/MemberName.tsx
new file mode 100644
index 0000000..254e11c
--- /dev/null
+++ b/packages/core/src/components/MemberName.tsx
@@ -0,0 +1,11 @@
+import { useContext } from "../context.js";
+import { MemberDeclarationContext } from "../context/member-declaration.js";
+
+export function MemberName() {
+  const declSymbol = useContext(MemberDeclarationContext);
+  if (!declSymbol) {
+    return "(no member declaration context)";
+  }
+
+  return <>{declSymbol.name}</>;
+}
diff --git a/packages/core/src/components/MemberScope.tsx b/packages/core/src/components/MemberScope.tsx
new file mode 100644
index 0000000..4e5b8b3
--- /dev/null
+++ b/packages/core/src/components/MemberScope.tsx
@@ -0,0 +1,40 @@
+import { Children } from "@alloy-js/core/jsx-runtime";
+import { OutputSymbol } from "../binder.js";
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import { OutputSymbolFlags } from "../binder.js";
+import { MemberScopeContext } from "../context/member-scope.js";
+export interface MemberScopeProps {
+  /**
+   * The name of the member scope.
+   */
+  name?: string;
+
+  /**
+   * The symbol that owns these members. This symbol must have either
+   * {@link OutputSymbolFlags.InstanceMemberContainer} or
+   * {@link OutputSymbolFlags.StaticMemberContainer}.
+   */
+  owner: OutputSymbol;
+  children?: Children;
+}
+
+/**
+ * Declare a member scope, which is a scope that holds instance and static
+ * members. This scope is then used for nested instance or static member
+ * declarations and resolution of instance members.
+ *
+ * @remarks
+ *
+ * The member scope contains scopes for both instance and static members.
+ * However, it does not affect the resolution of static members.
+ */
+export function MemberScope(props: MemberScopeProps) {
+  const context: MemberScopeContext = {
+    instanceMembers: props.owner.instanceMemberScope,
+    staticMembers: props.owner.staticMemberScope,
+  };
+  return <MemberScopeContext.Provider value={context}>
+      {props.children}
+    </MemberScopeContext.Provider>;
+}
diff --git a/packages/typescript/src/components/Name.tsx b/packages/core/src/components/Name.tsx
similarity index 58%
rename from packages/typescript/src/components/Name.tsx
rename to packages/core/src/components/Name.tsx
index 3840bd6..e1af89b 100644
--- a/packages/typescript/src/components/Name.tsx
+++ b/packages/core/src/components/Name.tsx
@@ -1,4 +1,5 @@
-import { DeclarationContext, useContext } from "@alloy-js/core";
+import { useContext } from "../context.js";
+import { DeclarationContext } from "../context/declaration.js";
 
 export function Name() {
   const declSymbol = useContext(DeclarationContext);
diff --git a/packages/core/src/components/Output.tsx b/packages/core/src/components/Output.tsx
index 40c11c6..1fa5ec2 100644
--- a/packages/core/src/components/Output.tsx
+++ b/packages/core/src/components/Output.tsx
@@ -4,12 +4,11 @@ import {
   getSymbolCreator,
   SymbolCreator,
 } from "../binder.js";
+import { BinderContext } from "../context/binder.js";
+import { NamePolicyContext } from "../context/name-policy.js";
 import { NamePolicy } from "../name-policy.js";
 import { SourceDirectory } from "./SourceDirectory.js";
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-import { BinderContext } from "../context/binder.js";
-import { NamePolicyContext } from "../context/name-policy.js";
 import { SourceFile } from "./SourceFile.js";
 
 export interface OutputProps {
diff --git a/packages/core/src/components/index.tsx b/packages/core/src/components/index.tsx
index dfe10c5..d5710c1 100644
--- a/packages/core/src/components/index.tsx
+++ b/packages/core/src/components/index.tsx
@@ -1,5 +1,9 @@
 export * from "./Declaration.js";
 export * from "./Indent.js";
+export * from "./MemberDeclaration.jsx";
+export * from "./MemberName.jsx";
+export * from "./MemberScope.jsx";
+export * from "./Name.jsx";
 export * from "./Output.js";
 export * from "./Scope.js";
 export * from "./SourceDirectory.js";
diff --git a/packages/core/src/context/assignment.ts b/packages/core/src/context/assignment.ts
new file mode 100644
index 0000000..39e31e1
--- /dev/null
+++ b/packages/core/src/context/assignment.ts
@@ -0,0 +1,57 @@
+import { OutputSymbol } from "../binder.js";
+import { ComponentContext, createContext, useContext } from "../context.js";
+
+export interface AssignmentContext {
+  /**
+   * The symbol that is the target of the current assignment.
+   */
+  target: OutputSymbol;
+
+  /**
+   * Whether the symbol has had a value assigned to it. Once the symbol has been
+   * assigned, subsequent assignments will have no effect.
+   */
+  isAssigned: boolean;
+}
+
+/**
+ * AssignmentContext provides the symbol that is the target of the current
+ * assignment.
+ *
+ * @remarks
+ *
+ * When a variable is declared, the symbol for the variable doesn't yet know
+ * what value it will hold, because that depends on the assignment to the
+ * variable in the variable declaration's initializer. This context provides the
+ * symbol that is the target of the current assignment, so that children of an
+ * assignment or initializer can provide additional symbol information.
+ *
+ * For example, when assigning an object value expression to a variable, the
+ * object value expression should use assignment context to provide the member
+ * symbols for the object value's properties.
+ */
+export const AssignmentContext: ComponentContext<AssignmentContext> =
+  createContext();
+
+/**
+ * Creates a new {@link (AssignmentContext:interface)}.
+ *
+ * @param target - The symbol that is the target of the current assignment.
+ * @returns A new {@link (AssignmentContext:interface)}.
+ */
+export function createAssignmentContext(
+  target: OutputSymbol,
+): AssignmentContext {
+  return {
+    isAssigned: false,
+    target,
+  };
+}
+
+export function getAssignmentSymbol() {
+  const assignmentContext = useContext(AssignmentContext);
+  if (assignmentContext) {
+    return assignmentContext.target;
+  }
+  return undefined;
+}
diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts
index 375e854..6bf5865 100644
--- a/packages/core/src/context/index.ts
+++ b/packages/core/src/context/index.ts
@@ -1,6 +1,9 @@
+export * from "./assignment.js";
 export * from "./binder.js";
 export * from "./declaration.js";
 export * from "./indent.js";
+export * from "./member-declaration.js";
+export * from "./member-scope.js";
 export * from "./name-policy.js";
 export * from "./scope.js";
 export * from "./source-directory.js";
diff --git a/packages/core/src/context/member-declaration.ts b/packages/core/src/context/member-declaration.ts
new file mode 100644
index 0000000..5829bab
--- /dev/null
+++ b/packages/core/src/context/member-declaration.ts
@@ -0,0 +1,10 @@
+import { OutputSymbol } from "../binder.js";
+import { ComponentContext, createContext } from "../context.js";
+
+/**
+ * Provides the symbol for the member currently being declared.
+ *
+ * @see {@link DeclarationContext} for getting the symbol for the current non-member declaration.
+ */
+export const MemberDeclarationContext: ComponentContext<OutputSymbol> =
+  createContext();
diff --git a/packages/core/src/context/member-scope.ts b/packages/core/src/context/member-scope.ts
new file mode 100644
index 0000000..f0b4d84
--- /dev/null
+++ b/packages/core/src/context/member-scope.ts
@@ -0,0 +1,17 @@
+import { OutputScope } from "../binder.js";
+import { ComponentContext, createContext, useContext } from "../context.js";
+
+/**
+ * The member scope context provides the instance and static member scopes that
+ * are used for member declarations and instance member resolution.
+ */
+export interface MemberScopeContext {
+  staticMembers?: OutputScope;
+  instanceMembers?: OutputScope;
+}
+export const MemberScopeContext: ComponentContext<MemberScopeContext> =
+  createContext();
+
+export function useMemberScope() {
+  return useContext(MemberScopeContext)!;
+}
diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts
index 07762f8..13edbaf 100644
--- a/packages/core/src/jsx-runtime.ts
+++ b/packages/core/src/jsx-runtime.ts
@@ -157,15 +157,17 @@ export function pushStack(component: Component<any>, props: Props) {
 
 export function popStack() {
   if (!shouldDebug) return;
-  const item = renderStack.pop();
+  renderStack.pop();
 }
 
 export function printRenderStack() {
   if (!shouldDebug) return;
 
+  // eslint-disable-next-line no-console
   console.error("Error rendering:");
   for (let i = renderStack.length - 1; i >= 0; i--) {
     const { component, props } = renderStack[i];
+    // eslint-disable-next-line no-console
     console.error(`    at ${component.name}(${inspectProps(props)})`);
   }
 }
diff --git a/packages/core/src/refkey.ts b/packages/core/src/refkey.ts
index 1b3eff2..2e7dea2 100644
--- a/packages/core/src/refkey.ts
+++ b/packages/core/src/refkey.ts
@@ -1,3 +1,5 @@
+import { markRaw } from "@vue/reactivity";
+
 const objectIds = new WeakMap<WeakKey, Refkey>();
 let objId = 0;
 
@@ -17,11 +19,16 @@ const RefkeySym: unique symbol = Symbol();
 export type Refkey = { key: string; [RefkeySym]: true };
 
 function createRefkey(key: string): Refkey {
-  return {
+  const refkey: Refkey = {
     key,
     [RefkeySym]: true,
   };
+
+  markRaw(refkey);
+
+  return refkey;
 }
+
 export function isRefkey(value: unknown): value is Refkey {
   return (
     typeof value === "object" &&
diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts
index 350898c..c2559aa 100644
--- a/packages/core/src/utils.ts
+++ b/packages/core/src/utils.ts
@@ -6,7 +6,11 @@ import {
   isComponentCreator,
   memo,
 } from "@alloy-js/core/jsx-runtime";
+import { mkdirSync, statSync, writeFileSync } from "node:fs";
+import { relative, resolve } from "pathe";
 import { code } from "./code.js";
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+import { OutputDirectory, OutputFile, render } from "./render.js";
 
 export interface JoinOptions {
   /**
@@ -223,6 +227,62 @@ export function stc<T extends {}>(Component: ComponentDefinition<T>) {
   };
 }
 
-function isIterable(obj: any): obj is Iterable<any> {
-  return obj != null && typeof obj[Symbol.iterator] === "function";
+/**
+ * A visitor to collect the output from {@link render}. Used by
+ * {@link traverseOutput}.
+ */
+export interface OutputVisitor {
+  visitDirectory(directory: OutputDirectory): void;
+  visitFile(file: OutputFile): void;
+}
+
+/**
+ * Traverse the output from {@link render} and call the visitor for each
+ * file and directory within it.
+ *
+ * @param sourceDirectory - The root directory to traverse.
+ * @param visitor - The visitor to call for each file and directory.
+ */
+export function traverseOutput(
+  sourceDirectory: OutputDirectory,
+  visitor: OutputVisitor,
+) {
+  visitor.visitDirectory(sourceDirectory);
+  for (const item of sourceDirectory.contents) {
+    if (item.kind === "directory") {
+      traverseOutput(item, visitor);
+    } else {
+      visitor.visitFile(item);
+    }
+  }
+}
+
+/**
+ * Write the output from {@link render} to the file system.
+ *
+ */
+export function writeOutput(output: OutputDirectory, basePath: string = "") {
+  traverseOutput(output, {
+    visitDirectory(directory) {
+      const path = resolve(basePath, directory.path);
+      if (statSync(path)) {
+        return;
+      }
+      // eslint-disable-next-line no-console
+      console.log("create", relative(process.cwd(), path));
+      mkdirSync(path, { recursive: true });
+    },
+    visitFile(file) {
+      const path = resolve(basePath, file.path);
+      if (statSync(path)) {
+        // eslint-disable-next-line no-console
+        console.log("overwrite", relative(process.cwd(), path));
+      } else {
+        // eslint-disable-next-line no-console
+        console.log("create", relative(process.cwd(), path));
+      }
+
+      writeFileSync(path, file.contents);
+    },
+  });
 }
diff --git a/packages/csharp/src/components/Class.tsx b/packages/csharp/src/components/Class.tsx
index 847f7e7..b3f0b14 100644
--- a/packages/csharp/src/components/Class.tsx
+++ b/packages/csharp/src/components/Class.tsx
@@ -5,14 +5,15 @@ import {
   getMethodModifier,
   MethodModifier,
 } from "../modifiers.js";
-import { useCSharpNamePolicy } from "../name-policy.js";
+import { CSharpElements, useCSharpNamePolicy } from "../name-policy.js";
 import { CSharpOutputSymbol } from "../symbols/csharp-output-symbol.js";
 import { createCSharpMemberScope, useCSharpScope } from "../symbols/scopes.js";
-import { Name } from "./Name.jsx";
-import { Parameters } from "./Parameters.jsx";
+import { Name } from "./Name.js";
+import { ParameterProps, Parameters } from "./Parameters.js";
 
 // properties for creating a class
 export interface ClassProps extends Omit<core.DeclarationProps, "nameKind"> {
+  name: string;
   accessModifier?: AccessModifier;
   typeParameters?: Record<string, core.Refkey>;
 }
@@ -36,14 +37,16 @@ export function Class(props: ClassProps) {
     scope.binder,
     scope,
     thisClassSymbol,
-    "class",
+    "class-decl",
   );
 
   let typeParams: string | undefined;
   if (props.typeParameters) {
     const typeParamNames = new Array<string>();
     for (const entry of Object.entries(props.typeParameters)) {
-      typeParamNames.push(entry[0]);
+      typeParamNames.push(
+        useCSharpNamePolicy().getName(entry[0], "type-parameter"),
+      );
       // create a symbol for each type param so its
       // refkey resolves to the type param's name
       scope.binder.createSymbol<CSharpOutputSymbol>({
@@ -68,20 +71,78 @@ export function Class(props: ClassProps) {
     </core.Declaration>;
 }
 
+export interface ClassConstructorProps {
+  accessModifier?: AccessModifier;
+  parameters?: Array<ParameterProps>;
+  refkey?: core.Refkey;
+  symbol?: core.OutputSymbol;
+  children?: core.Children;
+}
+
+// a C# class constructor
+export function ClassConstructor(props: ClassConstructorProps) {
+  const scope = useCSharpScope();
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
+    throw new Error(
+      "can't define a class constructor outside of a class-decl scope",
+    );
+  }
+
+  // fetch the class name from the scope
+  const name = useCSharpNamePolicy().getName(scope.owner.name, "class-method");
+  const ctorSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
+    name: name,
+    scope,
+    refkey: props.refkey ?? core.refkey(name),
+  });
+
+  // scope for ctor declaration
+  const ctorDeclScope = createCSharpMemberScope(
+    scope.binder,
+    scope,
+    ctorSymbol,
+    "constructor-decl",
+  );
+
+  const accessModifier = getAccessModifier(props.accessModifier);
+  const params = props.parameters ?
+    <Parameters parameters={props.parameters} />
+  : "";
+
+  // note that scope wraps the ctor decl so that the params get the correct scope
+  return <core.Declaration symbol={ctorSymbol}>
+    <core.Scope value={ctorDeclScope}>
+      {accessModifier}<Name />({params}){!props.children && " {}"}{props.children &&
+        <>
+          {"\n{"}
+            {props.children}
+          {"}"}
+        </>
+        }
+      </core.Scope>
+    </core.Declaration>;
+}
+
 // properties for creating a class member
 export interface ClassMemberProps {
-  accessModifier?: AccessModifier;
   name: string;
   type: core.Children;
+  accessModifier?: AccessModifier;
   refkey?: core.Refkey;
 }
 
 // a C# class member (i.e. a field within a class like "private int count")
 export function ClassMember(props: ClassMemberProps) {
-  const name = useCSharpNamePolicy().getName(props.name, "class-member");
+  let nameElement: CSharpElements = "class-member-private";
+  if (props.accessModifier === "public") {
+    nameElement = "class-member-public";
+  }
+  const name = useCSharpNamePolicy().getName(props.name, nameElement);
   const scope = useCSharpScope();
-  if (scope.kind !== "member" || scope.name !== "class") {
-    throw new Error("can't define a class member outside of a class scope");
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
+    throw new Error(
+      "can't define a class member outside of a class-decl scope",
+    );
   }
 
   const memberSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
@@ -98,9 +159,10 @@ export function ClassMember(props: ClassMemberProps) {
 // properties for creating a method
 export interface ClassMethodProps
   extends Omit<core.DeclarationProps, "nameKind"> {
+  name: string;
   accessModifier?: AccessModifier;
   methodModifier?: MethodModifier;
-  parameters?: Record<string, core.Children>;
+  parameters?: Array<ParameterProps>;
   returns?: core.Children;
 }
 
@@ -108,7 +170,7 @@ export interface ClassMethodProps
 export function ClassMethod(props: ClassMethodProps) {
   const name = useCSharpNamePolicy().getName(props.name!, "class-method");
   const scope = useCSharpScope();
-  if (scope.kind !== "member" || scope.name !== "class") {
+  if (scope.kind !== "member" || scope.name !== "class-decl") {
     throw new Error("can't define a class method outside of a class scope");
   }
 
@@ -118,15 +180,12 @@ export function ClassMethod(props: ClassMethodProps) {
     refkey: props.refkey ?? core.refkey(props.name),
   });
 
-  // this creates a new scope for the class method.
-  // members will automatically "inherit" this scope so
-  // that refkeys to them will produce the fully-qualified
-  // name e.g. Foo.Bar.
+  // scope for method declaration
   const methodScope = createCSharpMemberScope(
     scope.binder,
     scope,
     methodSymbol,
-    "method",
+    "method-decl",
   );
 
   const accessModifier = getAccessModifier(props.accessModifier);
@@ -136,15 +195,16 @@ export function ClassMethod(props: ClassMethodProps) {
   : "";
   const returns = props.returns ?? "void";
 
+  // note that scope wraps the method decl so that the params get the correct scope
   return <core.Declaration symbol={methodSymbol}>
+    <core.Scope value={methodScope}>
       {accessModifier}{methodModifier}{returns} <Name />({params}){!props.children && " {}"}{props.children &&
         <>
           {"\n{"}
-            <core.Scope value={methodScope}>
-              {props.children}
-            </core.Scope>
+            {props.children}
           {"}"}
         </>
         }
+      </core.Scope>
     </core.Declaration>;
 }
diff --git a/packages/csharp/src/components/Enum.tsx b/packages/csharp/src/components/Enum.tsx
index 5059604..360821e 100644
--- a/packages/csharp/src/components/Enum.tsx
+++ b/packages/csharp/src/components/Enum.tsx
@@ -7,6 +7,7 @@ import { Name } from "./Name.jsx";
 
 // properties for creating an enum
 export interface EnumProps extends Omit<core.DeclarationProps, "nameKind"> {
+  name: string;
   accessModifier?: AccessModifier;
 }
 
@@ -29,7 +30,7 @@ export function Enum(props: EnumProps) {
     scope.binder,
     scope,
     thisEnumSymbol,
-    "enum",
+    "enum-decl",
   );
 
   return <core.Declaration symbol={thisEnumSymbol}>
@@ -54,12 +55,20 @@ export interface EnumMemberProps {
 // a member within a C# enum
 export function EnumMember(props: EnumMemberProps) {
   const scope = useCSharpScope();
-  if (scope.kind === "member" && scope.name !== "enum") {
-    throw new Error("can't define an enum member outside of an enum scope");
+  if (scope.kind === "member" && scope.name !== "enum-decl") {
+    throw new Error(
+      "can't define an enum member outside of an enum-decl scope",
+    );
   }
+
   const name = useCSharpNamePolicy().getName(props.name, "enum-member");
+  const thisEnumValueSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
+    name: name,
+    scope,
+    refkey: props.refkey ?? core.refkey(props.name),
+  });
 
-  return <core.Declaration name={name} refkey={props.refkey}>
+  return <core.Declaration symbol={thisEnumValueSymbol}>
       <Name />
     </core.Declaration>;
 }
diff --git a/packages/csharp/src/components/Parameters.tsx b/packages/csharp/src/components/Parameters.tsx
index 4a1f53a..c577cda 100644
--- a/packages/csharp/src/components/Parameters.tsx
+++ b/packages/csharp/src/components/Parameters.tsx
@@ -1,17 +1,51 @@
 import * as core from "@alloy-js/core";
 import { useCSharpNamePolicy } from "../name-policy.js";
+import { CSharpOutputSymbol } from "../symbols/csharp-output-symbol.js";
+import { useCSharpScope } from "../symbols/scopes.js";
+import { Name } from "./Name.js";
+
+export interface ParameterProps {
+  name: string;
+  type: core.Children;
+  refkey?: core.Refkey;
+  symbol?: core.OutputSymbol;
+}
+
+// a constructor/method parameter
+export function Parameter(props: ParameterProps) {
+  const name = useCSharpNamePolicy().getName(props.name, "parameter");
+  const scope = useCSharpScope();
+  if (
+    scope.kind !== "member" ||
+    (scope.name !== "constructor-decl" && scope.name !== "method-decl")
+  ) {
+    throw new Error(
+      "can't define a parameter outside of a constructor-decl or method-decl scope",
+    );
+  }
+
+  const memberSymbol = scope.binder.createSymbol<CSharpOutputSymbol>({
+    name: name,
+    scope,
+    refkey: props.refkey ?? core.refkey(props.name),
+  });
+
+  return <core.Declaration symbol={memberSymbol}>
+      {props.type} <Name />
+    </core.Declaration>;
+}
 
 export interface ParametersProps {
   // param name and type
-  parameters: Record<string, core.Child>;
+  parameters: Array<ParameterProps>;
 }
 
-// param names and type
+// a collection of parameters
 export function Parameters(props: ParametersProps): Array<core.Child | string> {
   return core.mapJoin(
-    new Map(Object.entries(props.parameters)),
-    (name, type) => {
-      return [type, " ", useCSharpNamePolicy().getName(name, "parameter")];
+    props.parameters,
+    (param) => {
+      return <Parameter {...param} />;
     },
     { joiner: ", " },
   );
diff --git a/packages/csharp/src/components/ProjectDirectory.tsx b/packages/csharp/src/components/ProjectDirectory.tsx
index e68d51d..90da74c 100644
--- a/packages/csharp/src/components/ProjectDirectory.tsx
+++ b/packages/csharp/src/components/ProjectDirectory.tsx
@@ -18,6 +18,9 @@ export interface ProjectDirectoryProps {
   // directory name for source files. defaults to "src"
   srcDir?: string;
 
+  // the .NET TFM for this project. defaults to net8.0
+  targetFrameworkMoniker?: string;
+
   // child components of the project
   children?: core.Children;
 }
@@ -28,6 +31,10 @@ export function ProjectDirectory(props: ProjectDirectoryProps) {
     props.srcDir = "src";
   }
 
+  if (!props.targetFrameworkMoniker) {
+    props.targetFrameworkMoniker = "net8.0";
+  }
+
   return <core.SourceDirectory path={join(props.path, props.name)}>
     <core.SourceFile path={props.name+".csproj"} filetype="xml">
       {core.code`
@@ -35,6 +42,7 @@ export function ProjectDirectory(props: ProjectDirectoryProps) {
           <PropertyGroup>
             <Version>${props.version}</Version>
             <Description>${props.description}</Description>
+            <TargetFramework>${props.targetFrameworkMoniker}</TargetFramework>
           </PropertyGroup>
         </Project>
       `}
diff --git a/packages/csharp/src/components/stc/index.ts b/packages/csharp/src/components/stc/index.ts
index 9953f0f..ec0b396 100644
--- a/packages/csharp/src/components/stc/index.ts
+++ b/packages/csharp/src/components/stc/index.ts
@@ -2,10 +2,12 @@ import * as core from "@alloy-js/core";
 import * as base from "../index.js";
 
 export const Class = core.stc(base.Class);
+export const ClassConstructor = core.stc(base.ClassConstructor);
 export const ClassMember = core.stc(base.ClassMember);
 export const ClassMethod = core.stc(base.ClassMethod);
 export const Enum = core.stc(base.Enum);
 export const EnumMember = core.stc(base.EnumMember);
+export const Parameter = core.stc(base.Parameter);
 export const Parameters = core.stc(base.Parameters);
 export const ProjectDirectory = core.stc(base.ProjectDirectory);
 export const UsingDirective = core.stc(base.UsingDirective);
diff --git a/packages/csharp/src/name-policy.ts b/packages/csharp/src/name-policy.ts
index 530ec67..ba77e96 100644
--- a/packages/csharp/src/name-policy.ts
+++ b/packages/csharp/src/name-policy.ts
@@ -9,9 +9,11 @@ export type CSharpElements =
   | "enum-member"
   | "function"
   | "interface"
-  | "class-member"
+  | "class-member-private"
+  | "class-member-public"
   | "class-method"
-  | "parameter";
+  | "parameter"
+  | "type-parameter";
 
 // creates the C# naming policy
 export function createCSharpNamePolicy(): core.NamePolicy<CSharpElements> {
@@ -21,7 +23,9 @@ export function createCSharpNamePolicy(): core.NamePolicy<CSharpElements> {
       case "enum":
       case "enum-member":
       case "interface":
+      case "class-member-public":
       case "class-method":
+      case "type-parameter":
         return changecase.pascalCase(name);
       case "constant":
         return changecase.constantCase(name);
diff --git a/packages/csharp/src/symbols/scopes.ts b/packages/csharp/src/symbols/scopes.ts
index 7d864ed..7c2925e 100644
--- a/packages/csharp/src/symbols/scopes.ts
+++ b/packages/csharp/src/symbols/scopes.ts
@@ -20,7 +20,11 @@ export function createCSharpNamespaceScope(
 }
 
 // the kind of member scope. i.e. are we in an enum, class, etc
-export type CSharpMemberScopeName = "class" | "enum" | "method";
+export type CSharpMemberScopeName =
+  | "class-decl"
+  | "constructor-decl"
+  | "enum-decl"
+  | "method-decl";
 
 // indicates that the scope for a symbol resides within a type
 // e.g. for an enum value, class field etc, these would have
diff --git a/packages/docs/dist-build/scripts/components/Examples.d.ts b/packages/docs/dist-build/scripts/components/Examples.d.ts
index 305e556..40b6b3f 100644
--- a/packages/docs/dist-build/scripts/components/Examples.d.ts
+++ b/packages/docs/dist-build/scripts/components/Examples.d.ts
@@ -5,5 +5,5 @@ export interface ExamplesProps {
         tsdocComment?: DocComment;
     };
 }
-export declare function Examples(props: ExamplesProps): "" | import("@alloy-js/core").ComponentCreator<import("./MdxSection.js").MdxSectionProps>;
+export declare function Examples(props: ExamplesProps): import("@alloy-js/core").ComponentCreator<import("./MdxSection.js").MdxSectionProps> | "";
 //# sourceMappingURL=Examples.d.ts.map
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/Remarks.d.ts b/packages/docs/dist-build/scripts/components/Remarks.d.ts
index ae2d811..211d36b 100644
--- a/packages/docs/dist-build/scripts/components/Remarks.d.ts
+++ b/packages/docs/dist-build/scripts/components/Remarks.d.ts
@@ -5,5 +5,5 @@ export interface RemarksProps {
         tsdocComment?: DocComment;
     };
 }
-export declare function Remarks(props: RemarksProps): "" | import("@alloy-js/core").ComponentCreator<import("./MdxSection.js").MdxSectionProps>;
+export declare function Remarks(props: RemarksProps): import("@alloy-js/core").ComponentCreator<import("./MdxSection.js").MdxSectionProps> | "";
 //# sourceMappingURL=Remarks.d.ts.map
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/Summary.d.ts b/packages/docs/dist-build/scripts/components/Summary.d.ts
index 4c412c2..fdcb24d 100644
--- a/packages/docs/dist-build/scripts/components/Summary.d.ts
+++ b/packages/docs/dist-build/scripts/components/Summary.d.ts
@@ -5,5 +5,5 @@ export interface SummaryProps {
         tsdocComment?: DocComment;
     };
 }
-export declare function Summary(props: SummaryProps): "" | import("@alloy-js/core").ComponentCreator<import("./MdxParagraph.js").MdxParagraphProps>;
+export declare function Summary(props: SummaryProps): import("@alloy-js/core").ComponentCreator<import("./MdxParagraph.js").MdxParagraphProps> | "";
 //# sourceMappingURL=Summary.d.ts.map
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/TsDoc.d.ts.map b/packages/docs/dist-build/scripts/components/TsDoc.d.ts.map
index 2418d69..07247c8 100644
--- a/packages/docs/dist-build/scripts/components/TsDoc.d.ts.map
+++ b/packages/docs/dist-build/scripts/components/TsDoc.d.ts.map
@@ -1,1 +1,1 @@
-{"version":3,"file":"TsDoc.d.ts","sourceRoot":"","sources":["../../../scripts/components/TsDoc.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,QAAQ,EACd,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAC9D,OAAO,EAEL,WAAW,EACX,uBAAuB,EAEvB,UAAU,EACV,OAAO,EAGP,YAAY,EACZ,YAAY,EACZ,UAAU,EACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4DAA4D,CAAC;AAIvG,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,CA+CjD;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,YAAY,CAAC;CACpB;AACD,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,cAIxD;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,YAAY,CAAC;CACpB;AACD,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,UAExD;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,cAIpD;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB;;;GAepD;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,UAEtD;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,oBAAoB,GAAG,uBAAuB,EACpD,OAAO,EAAE,OAAO,GAAG,SAAS,uBAK7B"}
\ No newline at end of file
+{"version":3,"file":"TsDoc.d.ts","sourceRoot":"","sources":["../../../scripts/components/TsDoc.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,QAAQ,EACd,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAC9D,OAAO,EAEL,WAAW,EACX,uBAAuB,EAEvB,UAAU,EACV,OAAO,EAGP,YAAY,EACZ,YAAY,EACZ,UAAU,EACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4DAA4D,CAAC;AAIvG,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,CAkDjD;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,YAAY,CAAC;CACpB;AACD,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,cAUxD;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,YAAY,CAAC;CACpB;AACD,wBAAgB,cAAc,CAAC,KAAK,EAAE,mBAAmB,UAExD;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,cAIpD;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB;;;GAepD;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,UAEtD;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,oBAAoB,GAAG,uBAAuB,EACpD,OAAO,EAAE,OAAO,GAAG,SAAS,uBAK7B"}
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/TsDoc.js b/packages/docs/dist-build/scripts/components/TsDoc.js
index c990661..c86e70e 100644
--- a/packages/docs/dist-build/scripts/components/TsDoc.js
+++ b/packages/docs/dist-build/scripts/components/TsDoc.js
@@ -38,6 +38,9 @@ export function TsDoc(props) {
                 language: props.node.language,
             });
             break;
+        case DocNodeKind.SoftBreak:
+            content = "xxxxxxx \n";
+            break;
         default:
             console.log("Unknown TSDoc kind " + props.node.kind);
             break;
@@ -50,7 +53,14 @@ export function TsDoc(props) {
     }
 }
 export function TsDocParagraph(props) {
-    const trimmed = DocNodeTransforms.trimSpacesInParagraph(props.node);
+    let trimmed;
+    if (props.node.nodes[0]?.text?.match(/^\* |^\d+\. /)) {
+        trimmed = props.node;
+        console.log(props.node);
+    }
+    else {
+        trimmed = DocNodeTransforms.trimSpacesInParagraph(props.node);
+    }
     return trimmed.nodes.map((node) => TsDoc({ node }));
 }
 export function TsDocPlainText(props) {
diff --git a/packages/docs/dist-build/scripts/components/TsDoc.js.map b/packages/docs/dist-build/scripts/components/TsDoc.js.map
index 8ab737c..95e2a93 100644
--- a/packages/docs/dist-build/scripts/components/TsDoc.js.map
+++ b/packages/docs/dist-build/scripts/components/TsDoc.js.map
@@ -1,1 +1,1 @@
-{"version":3,"file":"TsDoc.js","sourceRoot":"","sources":["../../../scripts/components/TsDoc.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,GAAG,IAAI,OAAO,EACd,OAAO,EACP,MAAM,EACN,UAAU,GAEX,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAOL,WAAW,EACX,iBAAiB,GAIlB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,KAAK,GAAG,MAAM,gBAAgB,CAAC;AAMtC,MAAM,UAAU,KAAK,CAAC,KAAiB;IACrC,IAAI,OAAO,CAAC;IACZ,QAAQ,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACxB,KAAK,WAAW,CAAC,SAAS;YACxB,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAoB,EAAE,CAAC,CAAC;YACnE,MAAM;QACR,KAAK,WAAW,CAAC,QAAQ;YACvB,OAAO,GAAG,GAAG,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAmB,EAAE,CAAC,CAAC;YACjE,MAAM;QACR,KAAK,WAAW,CAAC,OAAO;YACtB,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAkB,EAAE,CAAC,CAAC;YAC/D,MAAM;QACR,KAAK,WAAW,CAAC,SAAS;YACxB,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAoB,EAAE,CAAC,CAAC;YACnE,MAAM;QACR,KAAK,WAAW,CAAC,OAAO;YACtB,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAkB,EAAE,CAAC,CAAC;YAC/D,MAAM;QACR,KAAK,WAAW,CAAC,KAAK;YACpB,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC;gBACzB,IAAI,EAAG,KAAK,CAAC,IAAiB,CAAC,OAAO;aACvC,CAAC,CAAC;YACH,MAAM;QACR,KAAK,WAAW,CAAC,UAAU;YACzB,UAAU;YACV,MAAM;QACR,KAAK,WAAW,CAAC,QAAQ;YACvB,UAAU;YACV,MAAM;QACR,KAAK,WAAW,CAAC,UAAU;YACzB,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;gBACjB,IAAI,EAAG,KAAK,CAAC,IAAsB,CAAC,IAAI;gBACxC,QAAQ,EAAG,KAAK,CAAC,IAAsB,CAAC,QAAQ;aACjD,CAAC,CAAC;YACH,MAAM;QACR;YACE,OAAO,CAAC,GAAG,CAAC,qBAAqB,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACrD,MAAM;IACV,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QAClB,OAAO,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,QAAQ,CACtE,OAAO,CACR,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAKD,MAAM,UAAU,cAAc,CAAC,KAA0B;IACvD,MAAM,OAAO,GAAG,iBAAiB,CAAC,qBAAqB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAEpE,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AACtD,CAAC;AAKD,MAAM,UAAU,cAAc,CAAC,KAA0B;IACvD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;AACzB,CAAC;AAMD,MAAM,UAAU,YAAY,CAAC,KAAwB;IACnD,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,KAAkB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE;QACvE,MAAM,EAAE,MAAM;KACf,CAAC,CAAC;AACL,CAAC;AAMD,MAAM,UAAU,YAAY,CAAC,KAAwB;IACnD,MAAM,UAAU,GAAG,eAAe,EAAE,CAAC;IACrC,IAAI,KAAK,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;QAC/B,MAAM,OAAO,GAAG,sBAAsB,CACpC,KAAK,CAAC,IAAI,CAAC,eAAe,EAC1B,UAAU,CACX,CAAC;QAEF,OAAO,GAAG,CAAC,SAAS,CAAC;YACnB,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC;YACvB,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,QAAQ;SAC9B,CAAC,CAAC;IACL,CAAC;SAAM,CAAC;QACN,OAAO,YAAY,KAAK,CAAC,IAAI,CAAC,cAAc,KAAK,KAAK,CAAC,IAAI,CAAC,QAAQ,MAAM,CAAC;IAC7E,CAAC;AACH,CAAC;AAMD,MAAM,UAAU,aAAa,CAAC,KAAyB;IACrD,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,sBAAsB,CACpC,IAAoD,EACpD,OAA4B;IAE5B,MAAM,QAAQ,GAAG,UAAU,CAAC,eAAe,CAAE,CAAC;IAE9C,OAAO,QAAQ,CAAC,2BAA2B,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,eAAe,CAAC;AAC7E,CAAC"}
\ No newline at end of file
+{"version":3,"file":"TsDoc.js","sourceRoot":"","sources":["../../../scripts/components/TsDoc.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,GAAG,IAAI,OAAO,EACd,OAAO,EACP,MAAM,EACN,UAAU,GAEX,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAOL,WAAW,EACX,iBAAiB,GAIlB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACtE,OAAO,KAAK,GAAG,MAAM,gBAAgB,CAAC;AAMtC,MAAM,UAAU,KAAK,CAAC,KAAiB;IACrC,IAAI,OAAO,CAAC;IACZ,QAAQ,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACxB,KAAK,WAAW,CAAC,SAAS;YACxB,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAoB,EAAE,CAAC,CAAC;YACnE,MAAM;QACR,KAAK,WAAW,CAAC,QAAQ;YACvB,OAAO,GAAG,GAAG,CAAC,aAAa,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAmB,EAAE,CAAC,CAAC;YACjE,MAAM;QACR,KAAK,WAAW,CAAC,OAAO;YACtB,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAkB,EAAE,CAAC,CAAC;YAC/D,MAAM;QACR,KAAK,WAAW,CAAC,SAAS;YACxB,OAAO,GAAG,GAAG,CAAC,cAAc,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAoB,EAAE,CAAC,CAAC;YACnE,MAAM;QACR,KAAK,WAAW,CAAC,OAAO;YACtB,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAkB,EAAE,CAAC,CAAC;YAC/D,MAAM;QACR,KAAK,WAAW,CAAC,KAAK;YACpB,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC;gBACzB,IAAI,EAAG,KAAK,CAAC,IAAiB,CAAC,OAAO;aACvC,CAAC,CAAC;YACH,MAAM;QACR,KAAK,WAAW,CAAC,UAAU;YACzB,UAAU;YACV,MAAM;QACR,KAAK,WAAW,CAAC,QAAQ;YACvB,UAAU;YACV,MAAM;QACR,KAAK,WAAW,CAAC,UAAU;YACzB,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;gBACjB,IAAI,EAAG,KAAK,CAAC,IAAsB,CAAC,IAAI;gBACxC,QAAQ,EAAG,KAAK,CAAC,IAAsB,CAAC,QAAQ;aACjD,CAAC,CAAC;YACH,MAAM;QACR,KAAK,WAAW,CAAC,SAAS;YACxB,OAAO,GAAG,YAAY,CAAC;YACvB,MAAM;QACR;YACE,OAAO,CAAC,GAAG,CAAC,qBAAqB,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACrD,MAAM;IACV,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QAClB,OAAO,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,QAAQ,CACtE,OAAO,CACR,CAAC;IACJ,CAAC;SAAM,CAAC;QACN,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAKD,MAAM,UAAU,cAAc,CAAC,KAA0B;IACvD,IAAI,OAAO,CAAC;IACZ,IAAK,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAkB,EAAE,IAAI,EAAE,KAAK,CAAC,cAAc,CAAC,EAAE,CAAC;QACvE,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC;QACrB,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;SAAM,CAAC;QACN,OAAO,GAAG,iBAAiB,CAAC,qBAAqB,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAChE,CAAC;IAED,OAAO,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;AACtD,CAAC;AAKD,MAAM,UAAU,cAAc,CAAC,KAA0B;IACvD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;AACzB,CAAC;AAMD,MAAM,UAAU,YAAY,CAAC,KAAwB;IACnD,OAAO,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,KAAkB,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE;QACvE,MAAM,EAAE,MAAM;KACf,CAAC,CAAC;AACL,CAAC;AAMD,MAAM,UAAU,YAAY,CAAC,KAAwB;IACnD,MAAM,UAAU,GAAG,eAAe,EAAE,CAAC;IACrC,IAAI,KAAK,CAAC,IAAI,CAAC,eAAe,EAAE,CAAC;QAC/B,MAAM,OAAO,GAAG,sBAAsB,CACpC,KAAK,CAAC,IAAI,CAAC,eAAe,EAC1B,UAAU,CACX,CAAC;QAEF,OAAO,GAAG,CAAC,SAAS,CAAC;YACnB,MAAM,EAAE,MAAM,CAAC,OAAO,CAAC;YACvB,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,QAAQ;SAC9B,CAAC,CAAC;IACL,CAAC;SAAM,CAAC;QACN,OAAO,YAAY,KAAK,CAAC,IAAI,CAAC,cAAc,KAAK,KAAK,CAAC,IAAI,CAAC,QAAQ,MAAM,CAAC;IAC7E,CAAC;AACH,CAAC;AAMD,MAAM,UAAU,aAAa,CAAC,KAAyB;IACrD,OAAO,KAAK,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;AAClC,CAAC;AAED,MAAM,UAAU,sBAAsB,CACpC,IAAoD,EACpD,OAA4B;IAE5B,MAAM,QAAQ,GAAG,UAAU,CAAC,eAAe,CAAE,CAAC;IAE9C,OAAO,QAAQ,CAAC,2BAA2B,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,eAAe,CAAC;AAC7E,CAAC"}
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/component/ComponentProps.d.ts b/packages/docs/dist-build/scripts/components/component/ComponentProps.d.ts
index da11eb8..addebd6 100644
--- a/packages/docs/dist-build/scripts/components/component/ComponentProps.d.ts
+++ b/packages/docs/dist-build/scripts/components/component/ComponentProps.d.ts
@@ -2,5 +2,5 @@ import type { ApiInterface } from "@microsoft/api-extractor-model";
 export interface ComponentPropsProps {
     propType?: ApiInterface;
 }
-export declare function ComponentProps(props: ComponentPropsProps): "" | import("@alloy-js/core").ComponentCreator<import("../MdxSection.js").MdxSectionProps>;
+export declare function ComponentProps(props: ComponentPropsProps): import("@alloy-js/core").ComponentCreator<import("../MdxSection.js").MdxSectionProps> | "";
 //# sourceMappingURL=ComponentProps.d.ts.map
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/function/FunctionOptions.d.ts b/packages/docs/dist-build/scripts/components/function/FunctionOptions.d.ts
index 1e07a24..4bdb461 100644
--- a/packages/docs/dist-build/scripts/components/function/FunctionOptions.d.ts
+++ b/packages/docs/dist-build/scripts/components/function/FunctionOptions.d.ts
@@ -2,5 +2,5 @@ import type { ApiFunction } from "@microsoft/api-extractor-model";
 export interface FunctionOptionsProps {
     fn: ApiFunction;
 }
-export declare function FunctionOptions(props: FunctionOptionsProps): "" | import("@alloy-js/core").ComponentCreator<import("../MdxSection.js").MdxSectionProps>;
+export declare function FunctionOptions(props: FunctionOptionsProps): import("@alloy-js/core").ComponentCreator<import("../MdxSection.js").MdxSectionProps> | "";
 //# sourceMappingURL=FunctionOptions.d.ts.map
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js b/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js
index 13bf9ec..551cd95 100644
--- a/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js
+++ b/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js
@@ -1,8 +1,6 @@
 import { Examples, FunctionOptions, FunctionParameters, FunctionReturn, FunctionSignature, MdxParagraph, MdxSection, Remarks, SeeAlso, Summary, } from "../stc/index.js";
 export function FunctionOverloadDoc(props) {
-    const root = props.omitOverloadIndex ?
-        MdxSection({ title: `Overload ${props.fn.overloadIndex}`, level: 2 })
-        : MdxParagraph();
+    const root = props.omitOverloadIndex ? MdxParagraph() : (MdxSection({ title: `Overload ${props.fn.overloadIndex}`, level: 2 }));
     return root.children(Summary({ type: props.fn }), FunctionSignature({ fn: props.fn }), FunctionParameters({ fn: props.fn }), FunctionOptions({ fn: props.fn }), FunctionReturn({ fn: props.fn }), Remarks({ type: props.fn }), Examples({ type: props.fn }), SeeAlso({ type: props.fn }));
 }
 //# sourceMappingURL=FunctionOverloadDoc.js.map
\ No newline at end of file
diff --git a/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js.map b/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js.map
index 18dbdb1..3c4c050 100644
--- a/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js.map
+++ b/packages/docs/dist-build/scripts/components/function/FunctionOverloadDoc.js.map
@@ -1,1 +1,1 @@
-{"version":3,"file":"FunctionOverloadDoc.js","sourceRoot":"","sources":["../../../../scripts/components/function/FunctionOverloadDoc.ts"],"names":[],"mappings":"AACA,OAAO,EACL,QAAQ,EACR,eAAe,EACf,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,OAAO,EACP,OAAO,EACP,OAAO,GACR,MAAM,iBAAiB,CAAC;AAOzB,MAAM,UAAU,mBAAmB,CAAC,KAA+B;IACjE,MAAM,IAAI,GACR,KAAK,CAAC,iBAAiB,CAAC,CAAC;QACvB,UAAU,CAAC,EAAE,KAAK,EAAE,YAAY,KAAK,CAAC,EAAE,CAAC,aAAa,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;QACvE,CAAC,CAAC,YAAY,EAAE,CAAC;IAEnB,OAAO,IAAI,CAAC,QAAQ,CAClB,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAC3B,iBAAiB,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EACnC,kBAAkB,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EACpC,eAAe,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EACjC,cAAc,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAChC,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAC3B,QAAQ,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAC5B,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAC5B,CAAC;AACJ,CAAC"}
\ No newline at end of file
+{"version":3,"file":"FunctionOverloadDoc.js","sourceRoot":"","sources":["../../../../scripts/components/function/FunctionOverloadDoc.ts"],"names":[],"mappings":"AACA,OAAO,EACL,QAAQ,EACR,eAAe,EACf,kBAAkB,EAClB,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,OAAO,EACP,OAAO,EACP,OAAO,GACR,MAAM,iBAAiB,CAAC;AAOzB,MAAM,UAAU,mBAAmB,CAAC,KAA+B;IACjE,MAAM,IAAI,GACR,KAAK,CAAC,iBAAiB,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,CACzC,UAAU,CAAC,EAAE,KAAK,EAAE,YAAY,KAAK,CAAC,EAAE,CAAC,aAAa,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC,CACtE,CAAC;IAEJ,OAAO,IAAI,CAAC,QAAQ,CAClB,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAC3B,iBAAiB,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EACnC,kBAAkB,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EACpC,eAAe,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EACjC,cAAc,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAChC,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAC3B,QAAQ,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,EAC5B,OAAO,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAC5B,CAAC;AACJ,CAAC"}
\ No newline at end of file
diff --git a/packages/docs/scripts/components/TsDoc.ts b/packages/docs/scripts/components/TsDoc.ts
index a53efd9..fa989f5 100644
--- a/packages/docs/scripts/components/TsDoc.ts
+++ b/packages/docs/scripts/components/TsDoc.ts
@@ -63,6 +63,9 @@ export function TsDoc(props: TsDocProps): Children {
         language: (props.node as DocFencedCode).language,
       });
       break;
+    case DocNodeKind.SoftBreak:
+      content = "\n";
+      break;
     default:
       console.log("Unknown TSDoc kind " + props.node.kind);
       break;
@@ -81,7 +84,12 @@ export interface TsDocParagraphProps {
   node: DocParagraph;
 }
 export function TsDocParagraph(props: TsDocParagraphProps) {
-  const trimmed = DocNodeTransforms.trimSpacesInParagraph(props.node);
+  let trimmed;
+  if ((props.node.nodes[0] as DocPlainText)?.text?.match(/^\* |^\d+\. /)) {
+    trimmed = props.node;
+  } else {
+    trimmed = DocNodeTransforms.trimSpacesInParagraph(props.node);
+  }
 
   return trimmed.nodes.map((node) => TsDoc({ node }));
 }
diff --git a/packages/docs/scripts/components/function/FunctionOverloadDoc.ts b/packages/docs/scripts/components/function/FunctionOverloadDoc.ts
index db9934f..8d61beb 100644
--- a/packages/docs/scripts/components/function/FunctionOverloadDoc.ts
+++ b/packages/docs/scripts/components/function/FunctionOverloadDoc.ts
@@ -19,9 +19,9 @@ export interface FunctionOverloadDocProps {
 
 export function FunctionOverloadDoc(props: FunctionOverloadDocProps) {
   const root =
-    props.omitOverloadIndex ?
+    props.omitOverloadIndex ? MdxParagraph() : (
       MdxSection({ title: `Overload ${props.fn.overloadIndex}`, level: 2 })
-    : MdxParagraph();
+    );
 
   return root.children(
     Summary({ type: props.fn }),
diff --git a/packages/typescript/src/components/Block.tsx b/packages/typescript/src/components/Block.tsx
index 7bb5d2f..b3f7614 100644
--- a/packages/typescript/src/components/Block.tsx
+++ b/packages/typescript/src/components/Block.tsx
@@ -4,5 +4,9 @@ export interface BlockProps {
   children?: Children;
 }
 export function Block(props: BlockProps) {
-  return [`{`, props.children, `}`];
+  return <>
+    {"{"}
+      {props.children}
+    {"}"}
+  </>;
 }
diff --git a/packages/typescript/src/components/ClassDeclaration.tsx b/packages/typescript/src/components/ClassDeclaration.tsx
new file mode 100644
index 0000000..74edced
--- /dev/null
+++ b/packages/typescript/src/components/ClassDeclaration.tsx
@@ -0,0 +1,145 @@
+import {
+  Children,
+  MemberDeclaration,
+  MemberName,
+  Name,
+  OutputSymbolFlags,
+  Refkey,
+  Scope,
+} from "@alloy-js/core";
+import { useTSNamePolicy } from "../name-policy.js";
+import { createTSSymbol, TSOutputSymbol } from "../symbols/ts-output-symbol.js";
+import { BaseDeclarationProps, Declaration } from "./Declaration.jsx";
+import {
+  FunctionDeclaration,
+  ParameterDescriptor,
+} from "./FunctionDeclaration.jsx";
+
+export interface ClassDeclarationProps extends BaseDeclarationProps {
+  extends?: Children;
+}
+
+/**
+ * Create a TypeScript class declaration.
+ *
+ * @remarks
+ *
+ * @example
+ *
+ * ```tsx
+ * const myPetRefkey = refkey();
+ * const Animal = refkey();
+ * const staticMember = refkey();
+ * const instanceMember = refkey();
+ *
+ * <ClassDeclaration name="Animal" refkey={Animal}>
+ *   <ClassMember public static name="something" type="string" refkey={staticMember}>
+ *     "hello"
+ *   </ClassMember>
+ *   <ClassMember public name="kind" type="string" />
+ *   <ClassMember public name="name" type="string" refkey={instanceMember} />
+ *   <ClassConstructor parameters="name: string">
+ *     this.name = name;
+ *   </ClassConstructor>
+ * </ClassDeclaration>
+ *
+ * <VarDeclaration const name="myPet" refkey={myPetRefkey}>
+ *   new {Animal}();
+ * </VarDeclaration>
+ *
+ * {staticMember}; // Animal.something
+ * <MemberReference path={[myPetRefkey, instanceMember]} /> // myPet.name
+ * {member(myPetRefkey, instanceMember)} // other option?
+ * ```
+ */
+export function ClassDeclaration(props: ClassDeclarationProps) {
+  const extendsPart = props.extends && <> extends {props.extends}</>;
+  const flags = OutputSymbolFlags.MemberContainer;
+
+  return <Declaration {...props} flags={flags} nameKind="class">
+    class <Name />{extendsPart} {"{"}
+      {props.children}
+    {"}"}
+  </Declaration>;
+}
+
+export interface ClassMemberProps {
+  name: string;
+  refkey?: Refkey;
+  public?: boolean;
+  private?: boolean;
+  protected?: boolean;
+  jsPrivate?: boolean;
+  static?: boolean;
+  children?: Children;
+}
+
+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}`;
+  }
+
+  return <MemberDeclaration symbol={sym} name={name} refkey={props.refkey}>
+    {
+      props.public && "public "
+    }{
+      props.private && "private "
+    }{
+      props.protected && "protected "
+    }{
+      props.static && "static "
+    }{props.children}
+  </MemberDeclaration>;
+}
+
+export interface ClassFieldProps extends ClassMemberProps {
+  type?: Children;
+  children?: Children;
+}
+
+export function ClassField(props: ClassFieldProps) {
+  const typeSection = props.type && <>: {props.type}</>;
+  const initializerSection = props.children && <> = {props.children}</>;
+
+  return <ClassMember {...props}>
+    <MemberName />{typeSection}{initializerSection};
+  </ClassMember>;
+}
+
+export interface ClassMethodProps extends ClassMemberProps {
+  async?: boolean;
+  parameters?: Record<string, Children | ParameterDescriptor>;
+  returnType?: Children;
+  children?: Children;
+}
+
+export function ClassMethod(props: ClassMethodProps) {
+  const returnType = props.returnType && <>: {props.returnType}</>;
+  return <ClassMember {...props}>
+    {
+      props.async && "async "
+    }<MemberName /><Scope name={props.name} kind="function">
+      (<FunctionDeclaration.Parameters parameters={props.parameters} />){returnType} {"{"}
+        {props.children}
+      {"}"}
+    </Scope>
+  </ClassMember>;
+}
diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx
index bf03faa..2c4278d 100644
--- a/packages/typescript/src/components/Declaration.tsx
+++ b/packages/typescript/src/components/Declaration.tsx
@@ -1,6 +1,8 @@
 import {
   Children,
   Declaration as CoreDeclaration,
+  MemberScope,
+  OutputSymbolFlags,
   refkey,
   Refkey,
 } from "@alloy-js/core";
@@ -13,8 +15,6 @@ import { TypeDeclaration } from "./TypeDeclaration.jsx";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { EnumDeclaration } from "./EnumDeclaration.jsx";
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
 import { InterfaceDeclaration } from "./Interface.jsx";
 
 export interface BaseDeclarationProps {
@@ -39,6 +39,11 @@ export interface BaseDeclarationProps {
    */
   default?: boolean;
 
+  /**
+   * Flags for the symbol created by this component.
+   */
+  flags?: OutputSymbolFlags;
+
   children?: Children;
 
   /**
@@ -60,13 +65,21 @@ export interface DeclarationProps extends BaseDeclarationProps {
  * directly, and instead prefer components for specific declarations, e.g.
  * {@link EnumDeclaration}, {@link InterfaceDeclaration},
  * {@link TypeDeclaration}, etc.
+ *
+ * @remarks
+ *
+ * This component will wrap its contents in a {@link @alloy-js/core#Declaration} component,
+ * so children can make use of declaration context. Additionally, if the
+ * provided symbol flags have {@link @alloy-js/core#OutputSymbolFlags.MemberContainer}, this
+ * component will create a {@link @alloy-js/core#MemberScope}.
+ *
  */
 export function Declaration(props: DeclarationProps) {
   const namePolicy = useTSNamePolicy();
 
-  let flags: TSSymbolFlags = TSSymbolFlags.None;
+  let tsFlags: TSSymbolFlags = TSSymbolFlags.None;
   if (props.kind && props.kind === "type") {
-    flags &= TSSymbolFlags.TypeSymbol;
+    tsFlags &= TSSymbolFlags.TypeSymbol;
   }
 
   const sym = createTSSymbol({
@@ -74,10 +87,19 @@ export function Declaration(props: DeclarationProps) {
     refkey: props.refkey ?? refkey(props.name),
     export: props.export,
     default: props.default,
-    flags,
+    flags: props.flags,
+    tsFlags,
   });
 
+  let children: Children;
+
+  if (sym.flags & OutputSymbolFlags.MemberContainer) {
+    children = <MemberScope owner={sym}>{props.children}</MemberScope>;
+  } else {
+    children = () => props.children;
+  }
+
   return <CoreDeclaration symbol={sym}>
-    {props.export ? "export " : ""}{props.default ? "default " : ""}{props.children}
+    {props.export ? "export " : ""}{props.default ? "default " : ""}{children}
   </CoreDeclaration>;
 }
diff --git a/packages/typescript/src/components/EnumDeclaration.tsx b/packages/typescript/src/components/EnumDeclaration.tsx
index 659a9aa..1a2cc02 100644
--- a/packages/typescript/src/components/EnumDeclaration.tsx
+++ b/packages/typescript/src/components/EnumDeclaration.tsx
@@ -1,20 +1,16 @@
 import {
   Declaration as CoreDeclaration,
-  Scope,
+  MemberScope,
+  Name,
+  OutputSymbolFlags,
   mapJoin,
   refkey,
   useBinder,
 } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
-import {
-  createTSMemberScope,
-  createTSSymbol,
-  useTSScope,
-} from "../symbols/index.js";
+import { createTSSymbol, useTSScope } from "../symbols/index.js";
 import { BaseDeclarationProps } from "./Declaration.js";
 import { EnumMember } from "./EnumMember.jsx";
-import { Name } from "./Name.js";
-
 export interface EnumDeclarationProps extends BaseDeclarationProps {
   /**
    * A JS object representing the enum member names and values.
@@ -36,10 +32,9 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
     refkey: props.refkey ?? refkey(name),
     default: props.default,
     export: props.export,
+    flags: OutputSymbolFlags.StaticMemberContainer,
   });
 
-  sym.memberScope = createTSMemberScope(binder, scope, sym);
-
   const jsValueMembers = mapJoin(
     Object.entries(props.jsValue ?? {}),
     ([name, value]) => {
@@ -50,9 +45,9 @@ export function EnumDeclaration(props: EnumDeclarationProps) {
 
   return <CoreDeclaration symbol={sym}>
     {props.export ? "export " : ""}{props.default ? "default " : ""}enum <Name /> {"{"}
-      <Scope value={sym.memberScope}>
+      <MemberScope owner={sym}>
         {jsValueMembers}{jsValueMembers.length > 0 && props.children && ",\n"}{props.children}
-      </Scope>
+      </MemberScope>
     {"}"}
   </CoreDeclaration>;
 }
diff --git a/packages/typescript/src/components/EnumMember.tsx b/packages/typescript/src/components/EnumMember.tsx
index 6e905e7..cd28c8c 100644
--- a/packages/typescript/src/components/EnumMember.tsx
+++ b/packages/typescript/src/components/EnumMember.tsx
@@ -1,10 +1,6 @@
-import { Children, Refkey } from "@alloy-js/core";
+import { Children, OutputSymbolFlags, Refkey } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
-import {
-  createTSSymbol,
-  TSOutputSymbol,
-  TSSymbolFlags,
-} from "../symbols/ts-output-symbol.js";
+import { createTSSymbol, TSOutputSymbol } from "../symbols/ts-output-symbol.js";
 import { ValueExpression } from "./ValueExpression.jsx";
 
 export interface EnumMemberProps {
@@ -44,7 +40,7 @@ export function EnumMember(props: EnumMemberProps) {
     sym = createTSSymbol({
       name,
       refkey: props.refkey,
-      flags: TSSymbolFlags.MemberSymbol,
+      flags: OutputSymbolFlags.StaticMember,
     });
   }
   const nameCode = sym ? sym.name : name;
diff --git a/packages/typescript/src/components/FunctionDeclaration.tsx b/packages/typescript/src/components/FunctionDeclaration.tsx
index ac13770..48234c4 100644
--- a/packages/typescript/src/components/FunctionDeclaration.tsx
+++ b/packages/typescript/src/components/FunctionDeclaration.tsx
@@ -4,6 +4,7 @@ import {
   findKeyedChild,
   findUnkeyedChildren,
   mapJoin,
+  Name,
   Refkey,
   refkey,
   Scope,
@@ -13,7 +14,6 @@ import { Children } from "@alloy-js/core/jsx-runtime";
 import { useTSNamePolicy } from "../name-policy.js";
 import { createTSSymbol, TSSymbolFlags, useTSScope } from "../symbols/index.js";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
-import { Name } from "./Name.js";
 
 export interface ParameterDescriptor {
   type: Children;
@@ -106,7 +106,7 @@ FunctionDeclaration.Parameters = taggedComponent(
           const sym = createTSSymbol({
             name: key,
             refkey: descriptor.refkey,
-            flags: TSSymbolFlags.ParameterSymbol,
+            tsFlags: TSSymbolFlags.ParameterSymbol,
           });
 
           return <>{namePolicy.getName(sym.name, "parameter")}{optionality}: {descriptor.type}</>;
diff --git a/packages/typescript/src/components/Interface.tsx b/packages/typescript/src/components/Interface.tsx
index 6cffd74..6415142 100644
--- a/packages/typescript/src/components/Interface.tsx
+++ b/packages/typescript/src/components/Interface.tsx
@@ -1,10 +1,8 @@
-import { Children, code } from "@alloy-js/core";
+import { Children, code, Name } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
-import { Declaration, DeclarationProps } from "./Declaration.js";
-import { Name } from "./Name.js";
+import { BaseDeclarationProps, Declaration } from "./Declaration.js";
 
-export interface InterfaceDeclarationProps
-  extends Omit<DeclarationProps, "nameKind"> {
+export interface InterfaceDeclarationProps extends BaseDeclarationProps {
   extends?: Children;
 }
 
@@ -38,6 +36,7 @@ export function InterfaceExpression(props: InterfaceExpressionProps) {
     }
   `;
 }
+
 export interface InterfaceMemberProps {
   name?: string;
   indexer?: Children;
diff --git a/packages/typescript/src/components/ObjectExpression.tsx b/packages/typescript/src/components/ObjectExpression.tsx
index ddffc5d..a46a1e5 100644
--- a/packages/typescript/src/components/ObjectExpression.tsx
+++ b/packages/typescript/src/components/ObjectExpression.tsx
@@ -1,21 +1,17 @@
 import {
+  AssignmentContext,
   Children,
   computed,
+  createAssignmentContext,
+  getAssignmentSymbol,
   mapJoin,
+  MemberScope,
   memo,
+  OutputSymbolFlags,
   Refkey,
-  Scope,
-  useBinder,
-  useContext,
 } from "@alloy-js/core";
-import {
-  createTSMemberScope,
-  createTSSymbol,
-  TSSymbolFlags,
-  useTSScope,
-} from "../symbols/index.js";
+import { createTSSymbol } from "../symbols/index.js";
 import { ValueExpression } from "./ValueExpression.js";
-import { AssignmentContext } from "./VarDeclaration.jsx";
 
 export interface ObjectExpressionProps {
   children?: Children;
@@ -26,13 +22,10 @@ export interface ObjectExpressionProps {
 }
 
 export function ObjectExpression(props: ObjectExpressionProps) {
-  const assignmentContext = useContext(AssignmentContext);
-  if (assignmentContext) {
-    assignmentContext.target.memberScope = createTSMemberScope(
-      useBinder(),
-      useTSScope(),
-      assignmentContext.target,
-    );
+  const assignmentSymbol = getAssignmentSymbol();
+
+  if (assignmentSymbol) {
+    assignmentSymbol.binder.addStaticMembersToSymbol(assignmentSymbol);
   }
 
   const elements = computed(() => {
@@ -69,12 +62,12 @@ export function ObjectExpression(props: ObjectExpressionProps) {
     if (elements.value.length === 0) {
       return "{}";
     } else {
-      if (assignmentContext) {
+      if (assignmentSymbol) {
         return <>
           {"{"}
-            <Scope value={assignmentContext.target.memberScope}>
+            <MemberScope owner={assignmentSymbol}>
               {elements.value}
-            </Scope>
+            </MemberScope>
           {"}"}
         </>;
       } else {
@@ -112,7 +105,7 @@ export function ObjectProperty(props: ObjectPropertyProps) {
     sym = createTSSymbol({
       name: props.name,
       refkey: props.refkey,
-      flags: TSSymbolFlags.MemberSymbol,
+      flags: OutputSymbolFlags.StaticMember,
     });
   }
 
@@ -127,9 +120,7 @@ export function ObjectProperty(props: ObjectPropertyProps) {
   }
 
   const assignmentContext: AssignmentContext | undefined = sym ?
-    {
-      target: sym,
-    }
+    createAssignmentContext(sym)
   : undefined;
   return <>
     {sym ? sym.name : name}: <AssignmentContext.Provider value={assignmentContext}>
diff --git a/packages/typescript/src/components/PackageDirectory.tsx b/packages/typescript/src/components/PackageDirectory.tsx
index 7272c39..0dd804a 100644
--- a/packages/typescript/src/components/PackageDirectory.tsx
+++ b/packages/typescript/src/components/PackageDirectory.tsx
@@ -27,6 +27,7 @@ export interface PackageDirectoryProps extends PackageJsonFileProps {
   exports?: PackageExports | ExportPath;
   tsConfig?: { outDir?: string };
   children?: Children;
+  path?: string;
 }
 
 export const PackageContext: ComponentContext<PackageContext> = createContext();
@@ -49,7 +50,7 @@ export function PackageDirectory(props: PackageDirectoryProps) {
   const packageContext = createPackageContext(
     props.name,
     props.version,
-    props.path,
+    props.path ?? ".",
   );
 
   if (props.exports) {
@@ -68,7 +69,7 @@ export function PackageDirectory(props: PackageDirectoryProps) {
     }
   }
 
-  return <SourceDirectory path={props.path}>
+  return <SourceDirectory path={props.path ?? "."}>
     <PackageContext.Provider value={packageContext}>
       <Scope value={packageContext.scope}>
         <PackageJsonFile {...props} />
diff --git a/packages/typescript/src/components/PackageJson.tsx b/packages/typescript/src/components/PackageJson.tsx
index 719c517..eddc466 100644
--- a/packages/typescript/src/components/PackageJson.tsx
+++ b/packages/typescript/src/components/PackageJson.tsx
@@ -5,7 +5,6 @@ import { usePackage } from "./PackageDirectory.js";
 export interface PackageJsonFileProps {
   name: string;
   version: string;
-  path: string;
   type?: "module" | "commonjs";
   devDependencies?: Record<string, string>;
   scripts?: Record<string, string>;
diff --git a/packages/typescript/src/components/TsConfigJson.tsx b/packages/typescript/src/components/TsConfigJson.tsx
index e3b0b8c..084e1ff 100644
--- a/packages/typescript/src/components/TsConfigJson.tsx
+++ b/packages/typescript/src/components/TsConfigJson.tsx
@@ -44,7 +44,6 @@ export function TSConfigJson(props: TSConfigJsonProps) {
         target: "esnext",
         module: "nodenext",
         strict: true,
-        verbatimModuleSyntax: true,
         declaration: true,
         sourceMap: true,
         declarationMap: true,
diff --git a/packages/typescript/src/components/TypeDeclaration.tsx b/packages/typescript/src/components/TypeDeclaration.tsx
index 6a5b70e..8fa9b0c 100644
--- a/packages/typescript/src/components/TypeDeclaration.tsx
+++ b/packages/typescript/src/components/TypeDeclaration.tsx
@@ -1,5 +1,5 @@
+import { Name } from "@alloy-js/core";
 import { BaseDeclarationProps, Declaration } from "./Declaration.js";
-import { Name } from "./Name.js";
 
 export interface TypeDeclarationProps extends BaseDeclarationProps {}
 
diff --git a/packages/typescript/src/components/VarDeclaration.tsx b/packages/typescript/src/components/VarDeclaration.tsx
index a416470..9ab64f1 100644
--- a/packages/typescript/src/components/VarDeclaration.tsx
+++ b/packages/typescript/src/components/VarDeclaration.tsx
@@ -1,14 +1,14 @@
 import {
+  AssignmentContext,
   Children,
-  ComponentContext,
   Declaration as CoreDeclaration,
-  createContext,
+  createAssignmentContext,
+  Name,
   refkey,
 } from "@alloy-js/core";
 import { useTSNamePolicy } from "../name-policy.js";
-import { createTSSymbol, TSOutputSymbol } from "../symbols/index.js";
+import { createTSSymbol } from "../symbols/index.js";
 import { BaseDeclarationProps } from "./Declaration.js";
-import { Name } from "./Name.js";
 
 export interface VarDeclarationProps extends BaseDeclarationProps {
   const?: boolean;
@@ -18,20 +18,6 @@ export interface VarDeclarationProps extends BaseDeclarationProps {
   type?: Children;
 }
 
-export interface AssignmentContext {
-  target: TSOutputSymbol;
-}
-
-/**
- * AssignmentContext tracks the target of an assignment. Value-producing
- * expressions that need symbol tracking should check to see if they are in
- * assignment context and if so handle it appropriately. Expressions which do
- * not need symbol tracking should unset assignment context so expressions
- * contained within them don't inadvertantly expose symbols.
- */
-export const AssignmentContext: ComponentContext<AssignmentContext> =
-  createContext();
-
 export function VarDeclaration(props: VarDeclarationProps) {
   const keyword =
     props.var ? "var"
@@ -46,9 +32,7 @@ export function VarDeclaration(props: VarDeclarationProps) {
     export: props.export,
   });
 
-  const assignmentContext: AssignmentContext = {
-    target: sym,
-  };
+  const assignmentContext = createAssignmentContext(sym);
 
   return <CoreDeclaration symbol={sym}>
     {props.export ? "export " : ""}{props.default ? "default " : ""}{keyword} <Name />{type} = <AssignmentContext.Provider value={assignmentContext}>
diff --git a/packages/typescript/src/components/index.ts b/packages/typescript/src/components/index.ts
index 0d19466..933342b 100644
--- a/packages/typescript/src/components/index.ts
+++ b/packages/typescript/src/components/index.ts
@@ -1,6 +1,7 @@
 export * from "./ArrayExpression.js";
 export * from "./BarrelFile.js";
 export * from "./Block.js";
+export * from "./ClassDeclaration.jsx";
 export * from "./Declaration.js";
 export * from "./EnumDeclaration.js";
 export * from "./EnumMember.js";
@@ -9,7 +10,6 @@ export * from "./FunctionCallExpression.js";
 export * from "./FunctionDeclaration.js";
 export * from "./ImportStatement.js";
 export * from "./Interface.js";
-export * from "./Name.js";
 export * from "./ObjectExpression.js";
 export * from "./PackageDirectory.js";
 export * from "./PackageJson.js";
diff --git a/packages/typescript/src/components/stc/index.ts b/packages/typescript/src/components/stc/index.ts
index 78cfc6a..379a65e 100644
--- a/packages/typescript/src/components/stc/index.ts
+++ b/packages/typescript/src/components/stc/index.ts
@@ -14,7 +14,6 @@ export const ImportStatements = stc(base.ImportStatements);
 export const InterfaceDeclaration = stc(base.InterfaceDeclaration);
 export const InterfaceExpression = stc(base.InterfaceExpression);
 export const InterfaceMember = stc(base.InterfaceMember);
-export const Name = stc(base.Name);
 export const ObjectExpression = stc(base.ObjectExpression);
 export const ObjectProperty = stc(base.ObjectProperty);
 export const PackageDirectory = stc(base.PackageDirectory);
@@ -25,3 +24,5 @@ export const TSConfigJson = stc(base.TSConfigJson);
 export const TypeDeclaration = stc(base.TypeDeclaration);
 export const ValueExpression = stc(base.ValueExpression);
 export const VarDeclaration = stc(base.VarDeclaration);
+export const ClassDeclaration = stc(base.ClassDeclaration);
+export const ClassField = stc(base.ClassField);
diff --git a/packages/typescript/src/name-conflict-resolver.ts b/packages/typescript/src/name-conflict-resolver.ts
index a351161..19ec9b9 100644
--- a/packages/typescript/src/name-conflict-resolver.ts
+++ b/packages/typescript/src/name-conflict-resolver.ts
@@ -5,10 +5,10 @@ export function tsNameConflictResolver(
   symbols: TSOutputSymbol[],
 ) {
   const goodNamedSymbols = symbols.filter(
-    (s) => ~s.flags & TSSymbolFlags.LocalImportSymbol,
+    (s) => ~s.tsFlags & TSSymbolFlags.LocalImportSymbol,
   );
   const badNamedSymbols = symbols.filter(
-    (s) => s.flags & TSSymbolFlags.LocalImportSymbol,
+    (s) => s.tsFlags & TSSymbolFlags.LocalImportSymbol,
   );
   let nameCount = 1;
   if (goodNamedSymbols.length > 1) {
diff --git a/packages/typescript/src/symbols/reference.ts b/packages/typescript/src/symbols/reference.ts
index 16e90fd..d84af89 100644
--- a/packages/typescript/src/symbols/reference.ts
+++ b/packages/typescript/src/symbols/reference.ts
@@ -1,4 +1,11 @@
-import { memo, Refkey, resolve, untrack, useContext } from "@alloy-js/core";
+import {
+  memo,
+  OutputSymbolFlags,
+  Refkey,
+  resolve,
+  untrack,
+  useContext,
+} from "@alloy-js/core";
 import { usePackage } from "../components/PackageDirectory.jsx";
 import { SourceFileContext } from "../components/SourceFile.jsx";
 import { TSOutputScope } from "./scopes.js";
@@ -7,7 +14,7 @@ import { TSModuleScope } from "./ts-module-scope.js";
 import { TSOutputSymbol } from "./ts-output-symbol.js";
 import { TSPackageScope } from "./ts-package-scope.js";
 
-export function ref(refkey: Refkey) {
+export function ref(refkey: Refkey): () => string {
   const sourceFile = useContext(SourceFileContext);
   const resolveResult = resolve<TSOutputScope, TSOutputSymbol>(
     refkey as Refkey,
@@ -18,7 +25,7 @@ export function ref(refkey: Refkey) {
       return "<Unresolved Symbol>";
     }
 
-    const { targetDeclaration, pathDown } = resolveResult.value;
+    const { targetDeclaration, pathDown, memberPath } = resolveResult.value;
 
     validateSymbolReachable(pathDown);
 
@@ -27,6 +34,7 @@ export function ref(refkey: Refkey) {
     // * module: target symbol is in a different module
     // * local: target symbol is within the current module
     const targetLocation = pathDown[0]?.kind ?? "local";
+    let localSymbol: TSOutputSymbol | undefined;
 
     if (targetLocation === "package") {
       // need package import
@@ -43,7 +51,6 @@ export function ref(refkey: Refkey) {
 
       const importSymbol = symbolPath[0];
 
-      let localSymbol;
       // find public dependency
       for (const module of sourcePackage.exportedSymbols.values()) {
         if (module.exportedSymbols.has(importSymbol.refkey)) {
@@ -60,9 +67,6 @@ export function ref(refkey: Refkey) {
             " is not exported from package",
         );
       }
-
-      symbolPath[0] = localSymbol;
-      return buildMemberExpression(symbolPath);
     } else if (targetLocation === "module") {
       const symbolPath = [
         ...(pathDown.slice(1) as TSMemberScope[]).map((s) => s.owner),
@@ -70,21 +74,37 @@ export function ref(refkey: Refkey) {
       ];
 
       const importSymbol = symbolPath[0];
-      const localSymbol = untrack(() =>
+      localSymbol = untrack(() =>
         sourceFile!.scope.addImport(importSymbol, pathDown[0] as TSModuleScope),
       );
-      symbolPath[0] = localSymbol;
-
-      return buildMemberExpression(symbolPath);
     }
 
-    // local reference
-    const syms = (pathDown as TSMemberScope[]).map((s) => s.owner);
-    syms.push(targetDeclaration);
-    return buildMemberExpression(syms);
+    if (memberPath && memberPath.length > 0) {
+      if (localSymbol) {
+        memberPath[0] = localSymbol;
+      }
+
+      return addThisPrefix(
+        targetDeclaration,
+        buildMemberExpression(memberPath),
+      );
+    } else {
+      return addThisPrefix(
+        localSymbol ?? targetDeclaration,
+        localSymbol ? localSymbol.name : targetDeclaration.name,
+      );
+    }
   });
 }
 
+function addThisPrefix(rootSymbol: TSOutputSymbol, name: string) {
+  if (rootSymbol.flags & OutputSymbolFlags.InstanceMember) {
+    return `this.${name}`;
+  } else {
+    return name;
+  }
+}
+
 function buildMemberExpression(symbolPath: TSOutputSymbol[]) {
   return symbolPath.map((sym) => sym.name).join(".");
 }
diff --git a/packages/typescript/src/symbols/ts-member-scope.ts b/packages/typescript/src/symbols/ts-member-scope.ts
index 7fb8204..38fd461 100644
--- a/packages/typescript/src/symbols/ts-member-scope.ts
+++ b/packages/typescript/src/symbols/ts-member-scope.ts
@@ -4,17 +4,20 @@ import { TSOutputSymbol } from "./ts-output-symbol.js";
 export interface TSMemberScope extends OutputScope {
   kind: "member";
   owner: TSOutputSymbol;
+  isStatic: boolean;
 }
 
 export function createTSMemberScope(
   binder: Binder,
   parent: OutputScope,
   owner: TSOutputSymbol,
+  isStatic: boolean = false,
 ): TSMemberScope {
   return binder.createScope<TSMemberScope>({
     kind: "member",
     name: "members",
     owner,
     parent,
+    isStatic,
   });
 }
diff --git a/packages/typescript/src/symbols/ts-module-scope.ts b/packages/typescript/src/symbols/ts-module-scope.ts
index 5bd0f37..3d4c37c 100644
--- a/packages/typescript/src/symbols/ts-module-scope.ts
+++ b/packages/typescript/src/symbols/ts-module-scope.ts
@@ -51,7 +51,7 @@ export function createTSModuleScope(
         binder,
         name: targetSymbol.name,
         refkey: refkey({}),
-        flags: TSSymbolFlags.LocalImportSymbol,
+        tsFlags: TSSymbolFlags.LocalImportSymbol,
       });
 
       this.importedSymbols.set(targetSymbol, localSymbol);
diff --git a/packages/typescript/src/symbols/ts-output-symbol.ts b/packages/typescript/src/symbols/ts-output-symbol.ts
index a5da586..71014c2 100644
--- a/packages/typescript/src/symbols/ts-output-symbol.ts
+++ b/packages/typescript/src/symbols/ts-output-symbol.ts
@@ -1,4 +1,10 @@
-import { Binder, OutputSymbol, Refkey, useScope } from "@alloy-js/core";
+import {
+  Binder,
+  OutputSymbol,
+  OutputSymbolFlags,
+  Refkey,
+  useDefaultScope,
+} from "@alloy-js/core";
 import { TSOutputScope } from "./scopes.js";
 import { TSMemberScope } from "./ts-member-scope.js";
 
@@ -8,14 +14,13 @@ export enum TSSymbolFlags {
   LocalImportSymbol = 1 << 0,
   TypeSymbol        = 1 << 1,
   ParameterSymbol   = 1 << 2,
-  MemberSymbol      = 1 << 3
 }
 
 export interface TSOutputSymbol extends OutputSymbol {
   scope: TSOutputScope;
   export: boolean;
   default: boolean;
-  flags: TSSymbolFlags;
+  tsFlags: TSSymbolFlags;
   memberScope?: TSMemberScope;
 }
 
@@ -26,11 +31,13 @@ export interface createTsSymbolOptions {
   scope?: TSOutputScope;
   export?: boolean;
   default?: boolean;
-  flags?: TSSymbolFlags;
+  flags?: OutputSymbolFlags;
+  tsFlags?: TSSymbolFlags;
 }
 
 export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
-  const scope = options.scope ?? (useScope() as TSOutputScope);
+  const scope =
+    options.scope ?? (useDefaultScope(options.flags) as TSOutputScope);
 
   if (scope.kind !== "module" && (options.export || options.default)) {
     throw new Error("Can't export symbol from non-module scope");
@@ -44,7 +51,8 @@ export function createTSSymbol(options: createTsSymbolOptions): TSOutputSymbol {
     refkey: options.refkey,
     export: !!options.export,
     default: !!options.default,
-    flags: options.flags ?? TSSymbolFlags.None,
+    flags: options.flags ?? OutputSymbolFlags.None,
+    tsFlags: options.tsFlags ?? TSSymbolFlags.None,
   });
 
   if (options.export && scope.kind === "module") {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a8204c6..fc4dfaa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -387,17 +387,57 @@ importers:
         specifier: 'catalog:'
         version: 2.0.5(@types/node@22.2.0)
 
-  packages/sample:
+  packages/typescript:
     dependencies:
       '@alloy-js/core':
         specifier: workspace:~
         version: link:../core
+      change-case:
+        specifier: 'catalog:'
+        version: 5.4.4
+      pathe:
+        specifier: 'catalog:'
+        version: 1.1.2
+    devDependencies:
+      '@babel/cli':
+        specifier: 'catalog:'
+        version: 7.24.8(@babel/core@7.25.2)
+      '@babel/preset-typescript':
+        specifier: 'catalog:'
+        version: 7.24.7(@babel/core@7.25.2)
+      '@microsoft/api-extractor':
+        specifier: ^7.47.7
+        version: 7.47.7(@types/node@22.2.0)
+      '@rollup/plugin-babel':
+        specifier: 'catalog:'
+        version: 6.0.4(@babel/core@7.25.2)(@types/babel__core@7.20.5)(rollup@4.20.0)
+      '@rollup/plugin-typescript':
+        specifier: 'catalog:'
+        version: 11.1.6(rollup@4.20.0)(tslib@2.6.3)(typescript@5.5.4)
+      babel-preset-alloy:
+        specifier: workspace:~
+        version: link:../babel-preset-alloy
+      concurrently:
+        specifier: 'catalog:'
+        version: 8.2.2
+      typescript:
+        specifier: 'catalog:'
+        version: 5.5.4
+      vitest:
+        specifier: 'catalog:'
+        version: 2.0.5(@types/node@22.2.0)
+
+  samples/basic-project:
+    dependencies:
+      '@alloy-js/core':
+        specifier: workspace:~
+        version: link:../../packages/core
       '@alloy-js/java':
         specifier: workspace:~
-        version: link:../java
+        version: link:../../packages/java
       '@alloy-js/typescript':
         specifier: workspace:~
-        version: link:../typescript
+        version: link:../../packages/typescript
     devDependencies:
       '@babel/cli':
         specifier: 'catalog:'
@@ -416,7 +456,7 @@ importers:
         version: 20.14.15
       babel-preset-alloy:
         specifier: workspace:~
-        version: link:../babel-preset-alloy
+        version: link:../../packages/babel-preset-alloy
       concurrently:
         specifier: 'catalog:'
         version: 8.2.2
@@ -427,17 +467,17 @@ importers:
         specifier: 'catalog:'
         version: 2.0.5(@types/node@20.14.15)
 
-  packages/typescript:
+  samples/client-emitter:
     dependencies:
       '@alloy-js/core':
         specifier: workspace:~
-        version: link:../core
-      change-case:
-        specifier: 'catalog:'
-        version: 5.4.4
-      pathe:
-        specifier: 'catalog:'
-        version: 1.1.2
+        version: link:../../packages/core
+      '@alloy-js/java':
+        specifier: workspace:~
+        version: link:../../packages/java
+      '@alloy-js/typescript':
+        specifier: workspace:~
+        version: link:../../packages/typescript
     devDependencies:
       '@babel/cli':
         specifier: 'catalog:'
@@ -445,18 +485,18 @@ importers:
       '@babel/preset-typescript':
         specifier: 'catalog:'
         version: 7.24.7(@babel/core@7.25.2)
-      '@microsoft/api-extractor':
-        specifier: ^7.47.7
-        version: 7.47.7(@types/node@22.2.0)
       '@rollup/plugin-babel':
         specifier: 'catalog:'
         version: 6.0.4(@babel/core@7.25.2)(@types/babel__core@7.20.5)(rollup@4.20.0)
       '@rollup/plugin-typescript':
         specifier: 'catalog:'
         version: 11.1.6(rollup@4.20.0)(tslib@2.6.3)(typescript@5.5.4)
+      '@types/node':
+        specifier: 'catalog:'
+        version: 20.14.15
       babel-preset-alloy:
         specifier: workspace:~
-        version: link:../babel-preset-alloy
+        version: link:../../packages/babel-preset-alloy
       concurrently:
         specifier: 'catalog:'
         version: 8.2.2
@@ -465,7 +505,7 @@ importers:
         version: 5.5.4
       vitest:
         specifier: 'catalog:'
-        version: 2.0.5(@types/node@22.2.0)
+        version: 2.0.5(@types/node@20.14.15)
 
 packages:
 
@@ -3509,37 +3549,6 @@ packages:
     engines: {node: ^18.0.0 || >=20.0.0}
     hasBin: true
 
-  vite@5.4.0:
-    resolution: {integrity: sha512-5xokfMX0PIiwCMCMb9ZJcMyh5wbBun0zUzKib+L65vAZ8GY9ePZMXxFrHbr/Kyll2+LSCY7xtERPpxkBDKngwg==}
-    engines: {node: ^18.0.0 || >=20.0.0}
-    hasBin: true
-    peerDependencies:
-      '@types/node': ^18.0.0 || >=20.0.0
-      less: '*'
-      lightningcss: ^1.21.0
-      sass: '*'
-      sass-embedded: '*'
-      stylus: '*'
-      sugarss: '*'
-      terser: ^5.4.0
-    peerDependenciesMeta:
-      '@types/node':
-        optional: true
-      less:
-        optional: true
-      lightningcss:
-        optional: true
-      sass:
-        optional: true
-      sass-embedded:
-        optional: true
-      stylus:
-        optional: true
-      sugarss:
-        optional: true
-      terser:
-        optional: true
-
   vite@5.4.3:
     resolution: {integrity: sha512-IH+nl64eq9lJjFqU+/yrRnrHPVTlgy42/+IzbOdaFDVlyLgI/wDlf+FCobXLX1cT0X5+7LMyH1mIy2xJdLfo8Q==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -4033,7 +4042,7 @@ snapshots:
   '@babel/helper-member-expression-to-functions@7.24.8':
     dependencies:
       '@babel/traverse': 7.25.3
-      '@babel/types': 7.25.2
+      '@babel/types': 7.25.6
     transitivePeerDependencies:
       - supports-color
 
@@ -4044,7 +4053,7 @@ snapshots:
   '@babel/helper-module-imports@7.24.7':
     dependencies:
       '@babel/traverse': 7.25.3
-      '@babel/types': 7.25.2
+      '@babel/types': 7.25.6
     transitivePeerDependencies:
       - supports-color
 
@@ -4060,7 +4069,7 @@ snapshots:
 
   '@babel/helper-optimise-call-expression@7.24.7':
     dependencies:
-      '@babel/types': 7.25.2
+      '@babel/types': 7.25.6
 
   '@babel/helper-plugin-utils@7.24.8': {}
 
@@ -4083,7 +4092,7 @@ snapshots:
   '@babel/helper-skip-transparent-expression-wrappers@7.24.7':
     dependencies:
       '@babel/traverse': 7.25.3
-      '@babel/types': 7.25.2
+      '@babel/types': 7.25.6
     transitivePeerDependencies:
       - supports-color
 
@@ -7602,7 +7611,7 @@ snapshots:
       debug: 4.3.6
       pathe: 1.1.2
       tinyrainbow: 1.2.0
-      vite: 5.4.0(@types/node@20.14.15)
+      vite: 5.4.3(@types/node@20.14.15)
     transitivePeerDependencies:
       - '@types/node'
       - less
@@ -7620,7 +7629,7 @@ snapshots:
       debug: 4.3.6
       pathe: 1.1.2
       tinyrainbow: 1.2.0
-      vite: 5.4.0(@types/node@22.2.0)
+      vite: 5.4.3(@types/node@22.2.0)
     transitivePeerDependencies:
       - '@types/node'
       - less
@@ -7632,24 +7641,15 @@ snapshots:
       - supports-color
       - terser
 
-  vite@5.4.0(@types/node@20.14.15):
+  vite@5.4.3(@types/node@20.14.15):
     dependencies:
       esbuild: 0.23.0
-      postcss: 8.4.41
+      postcss: 8.4.45
       rollup: 4.20.0
     optionalDependencies:
       '@types/node': 20.14.15
       fsevents: 2.3.3
 
-  vite@5.4.0(@types/node@22.2.0):
-    dependencies:
-      esbuild: 0.23.0
-      postcss: 8.4.41
-      rollup: 4.20.0
-    optionalDependencies:
-      '@types/node': 22.2.0
-      fsevents: 2.3.3
-
   vite@5.4.3(@types/node@22.2.0):
     dependencies:
       esbuild: 0.23.0
@@ -7681,7 +7681,7 @@ snapshots:
       tinybench: 2.9.0
       tinypool: 1.0.0
       tinyrainbow: 1.2.0
-      vite: 5.4.0(@types/node@20.14.15)
+      vite: 5.4.3(@types/node@20.14.15)
       vite-node: 2.0.5(@types/node@20.14.15)
       why-is-node-running: 2.3.0
     optionalDependencies:
@@ -7714,7 +7714,7 @@ snapshots:
       tinybench: 2.9.0
       tinypool: 1.0.0
       tinyrainbow: 1.2.0
-      vite: 5.4.0(@types/node@22.2.0)
+      vite: 5.4.3(@types/node@22.2.0)
       vite-node: 2.0.5(@types/node@22.2.0)
       why-is-node-running: 2.3.0
     optionalDependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index bb75d52..4990a7b 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,5 +1,6 @@
 packages:
   - packages/*
+  - samples/*
 catalog:
   "@babel/cli": ^7.24.7
   "@babel/core": ^7.24.7
diff --git a/packages/sample/babel.config.cjs b/samples/basic-project/babel.config.cjs
similarity index 100%
rename from packages/sample/babel.config.cjs
rename to samples/basic-project/babel.config.cjs
diff --git a/packages/sample/package.json b/samples/basic-project/package.json
similarity index 100%
rename from packages/sample/package.json
rename to samples/basic-project/package.json
diff --git a/packages/sample/src/index-stc.ts b/samples/basic-project/src/index-stc.ts
similarity index 100%
rename from packages/sample/src/index-stc.ts
rename to samples/basic-project/src/index-stc.ts
diff --git a/packages/sample/src/index.tsx b/samples/basic-project/src/index.tsx
similarity index 100%
rename from packages/sample/src/index.tsx
rename to samples/basic-project/src/index.tsx
diff --git a/packages/sample/src/java.tsx b/samples/basic-project/src/java.tsx
similarity index 100%
rename from packages/sample/src/java.tsx
rename to samples/basic-project/src/java.tsx
diff --git a/packages/sample/src/write-output.ts b/samples/basic-project/src/write-output.ts
similarity index 100%
rename from packages/sample/src/write-output.ts
rename to samples/basic-project/src/write-output.ts
diff --git a/packages/sample/tsconfig.json b/samples/basic-project/tsconfig.json
similarity index 60%
rename from packages/sample/tsconfig.json
rename to samples/basic-project/tsconfig.json
index aef29bc..fcdaa5e 100644
--- a/packages/sample/tsconfig.json
+++ b/samples/basic-project/tsconfig.json
@@ -1,9 +1,13 @@
 {
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
-    "noEmit": true,
+    "emitDeclarationOnly": true,
     "outDir": "dist"
   },
+  "references": [
+    { "path": "../../packages/core" },
+    { "path": "../../packages/typescript" }
+  ],
   "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"],
   "exclude": ["node_modules", "dist"]
 }
diff --git a/packages/sample/vitest.config.ts b/samples/basic-project/vitest.config.ts
similarity index 100%
rename from packages/sample/vitest.config.ts
rename to samples/basic-project/vitest.config.ts
diff --git a/samples/client-emitter/babel.config.cjs b/samples/client-emitter/babel.config.cjs
new file mode 100644
index 0000000..bc7d10d
--- /dev/null
+++ b/samples/client-emitter/babel.config.cjs
@@ -0,0 +1,4 @@
+module.exports = {
+  sourceMaps: true,
+  presets: ["@babel/preset-typescript", "babel-preset-alloy"]
+};
\ No newline at end of file
diff --git a/samples/client-emitter/package.json b/samples/client-emitter/package.json
new file mode 100644
index 0000000..8f2dc8e
--- /dev/null
+++ b/samples/client-emitter/package.json
@@ -0,0 +1,38 @@
+{
+  "name": "@alloy-js/sample-client-emitter",
+  "private": "true",
+  "version": "1.0.0",
+  "description": "",
+  "main": "index.js",
+  "scripts": {
+    "build-src": "babel src -d dist/src --extensions .ts,.tsx",
+    "build-tsc": "tsc -p .",
+    "build": "npm run build-src && npm run build-tsc",
+    "watch-src": "babel src -d dist/src --extensions .ts,.tsx --watch",
+    "watch-tsc": "tsc -p . --watch",
+    "watch": "concurrently --kill-others \"npm run watch-src\" \"npm run watch-tsc\""
+  },
+  "keywords": [],
+  "author": "brian.terlson@microsoft.com",
+  "license": "ISC",
+  "dependencies": {
+    "@alloy-js/core": "workspace:~",
+    "@alloy-js/typescript": "workspace:~",
+    "@alloy-js/java": "workspace:~"
+  },
+  "devDependencies": {
+    "@babel/cli": "catalog:",
+    "@babel/preset-typescript": "catalog:",
+    "@rollup/plugin-babel": "catalog:",
+    "@rollup/plugin-typescript": "catalog:",
+    "@types/node": "catalog:",
+    "babel-preset-alloy": "workspace:~",
+    "concurrently": "catalog:",
+    "typescript": "catalog:",
+    "vitest": "catalog:"
+  },
+  "overrides": {
+    "esbuild": "0.23"
+  },
+  "type": "module"
+}
diff --git a/samples/client-emitter/src/components/Client.tsx b/samples/client-emitter/src/components/Client.tsx
new file mode 100644
index 0000000..29241b1
--- /dev/null
+++ b/samples/client-emitter/src/components/Client.tsx
@@ -0,0 +1,15 @@
+import { mapJoin, refkey } from "@alloy-js/core";
+import * as ts from "@alloy-js/typescript";
+import { useApi } from "../context/api.js";
+import { ClientMethod } from "./ClientMethod.jsx";
+
+export function Client() {
+  const schema = useApi().schema;
+  const operations = schema.operations;
+  const methods = mapJoin(operations, (op) => <ClientMethod operation={op} />);
+  const name = `${schema.name}Client`;
+
+  return <ts.ClassDeclaration name={name} export default refkey={refkey(schema)}>
+    {methods}
+  </ts.ClassDeclaration>;
+}
diff --git a/samples/client-emitter/src/components/ClientMethod.tsx b/samples/client-emitter/src/components/ClientMethod.tsx
new file mode 100644
index 0000000..faab96a
--- /dev/null
+++ b/samples/client-emitter/src/components/ClientMethod.tsx
@@ -0,0 +1,77 @@
+import { Children, code, refkey } from "@alloy-js/core";
+import * as ts from "@alloy-js/typescript";
+import { useApi } from "../context/api.js";
+import { RestApiOperation } from "../schema.js";
+
+export interface ClientMethodProps {
+  operation: RestApiOperation;
+}
+
+export function ClientMethod(props: ClientMethodProps) {
+  const api = useApi();
+  const op = props.operation;
+
+  // get the parameters based on the spec's requestBody
+  const parameters: Record<string, ts.ParameterDescriptor> = {};
+
+  const endpointParam = op.endpoint.match(/:(\w+)$/)?.[1];
+  if (endpointParam) {
+    parameters[endpointParam] = {
+      type: "string",
+      refkey: refkey(op, endpointParam),
+    };
+  }
+
+  if (op.requestBody) {
+    parameters["body"] = {
+      type: refkey(api.resolveReference(op.requestBody)),
+      refkey: refkey(op, "requestBody"),
+    };
+  }
+
+  // get the return type based on the spec's responseBody.
+  let returnType: Children;
+  if (op.responseBody === undefined) {
+    returnType = "Promise<void>";
+  } else {
+    const responseModel = api.resolveReference(op.responseBody);
+
+    const reference: Children = [refkey(responseModel)];
+    if (op.responseBody.array) {
+      reference.push("[]");
+    }
+
+    returnType = code`Promise<${reference}>`;
+  }
+
+  // get the url endpoint, constructed from possible path parameters
+  let endpoint: Children;
+  if (endpointParam) {
+    endpoint =
+      <>
+        "{op.endpoint.slice(0, -endpointParam.length - 1)}" + {refkey(op, endpointParam)}
+      </>;
+  } else {
+    endpoint = <>"{op.endpoint}"</>;
+  }
+
+  // get the fetch options
+  const options = op.verb ===
+    "post" && <>, 
+      <ts.ObjectExpression>
+        <ts.ObjectProperty name="method" jsValue={"POST"} />,
+        <ts.ObjectProperty name="body">JSON.stringify({refkey(op, "requestBody")})</ts.ObjectProperty>
+      </ts.ObjectExpression>
+    </>;
+
+  // assemble the method
+  return <ts.ClassMethod async name={op.name} parameters={parameters} returnType={returnType}>
+    const response = await fetch({endpoint}{options});
+    
+    if (!response.ok) <ts.Block>
+      throw new Error("Request failed: " + response.status);
+    </ts.Block>
+
+    return response.json() as {returnType};
+  </ts.ClassMethod>;
+}
diff --git a/samples/client-emitter/src/components/Model.tsx b/samples/client-emitter/src/components/Model.tsx
new file mode 100644
index 0000000..4f6e1b5
--- /dev/null
+++ b/samples/client-emitter/src/components/Model.tsx
@@ -0,0 +1,15 @@
+import { mapJoin, refkey } from "@alloy-js/core";
+import * as ts from "@alloy-js/typescript";
+import { RestApiModel } from "../schema.js";
+import { ModelProperty } from "./ModelProperty.jsx";
+interface ModelProps {
+  model: RestApiModel;
+}
+export function Model(props: ModelProps) {
+  const members = mapJoin(props.model.properties, (
+    prop,
+  ) => <ModelProperty property={prop} />);
+  return <ts.InterfaceDeclaration export name={props.model.name} refkey={refkey(props.model)}>
+    {members}
+  </ts.InterfaceDeclaration>;
+}
diff --git a/samples/client-emitter/src/components/ModelProperty.tsx b/samples/client-emitter/src/components/ModelProperty.tsx
new file mode 100644
index 0000000..80650a9
--- /dev/null
+++ b/samples/client-emitter/src/components/ModelProperty.tsx
@@ -0,0 +1,26 @@
+import { Children, refkey } from "@alloy-js/core";
+import * as ts from "@alloy-js/typescript";
+import { useApi } from "../context/api.js";
+import { RestApiModelProperty } from "../schema.js";
+import { Model } from "./Model.jsx";
+interface ModelProps {
+  property: RestApiModelProperty;
+}
+export function ModelProperty(props: ModelProps) {
+  let memberType: Children;
+
+  const apiType = props.property.type;
+
+  if (typeof apiType === "object") {
+    if ("ref" in apiType) {
+      const model = useApi().resolveReference(apiType);
+      memberType = refkey(model);
+    } else {
+      memberType = <Model model={apiType} />;
+    }
+  } else {
+    memberType = apiType;
+  }
+
+  return <ts.InterfaceMember name={props.property.name} type={memberType} />;
+}
diff --git a/samples/client-emitter/src/components/index.ts b/samples/client-emitter/src/components/index.ts
new file mode 100644
index 0000000..79a7835
--- /dev/null
+++ b/samples/client-emitter/src/components/index.ts
@@ -0,0 +1,4 @@
+export * from "./Client.jsx";
+export * from "./ClientMethod.jsx";
+export * from "./Model.jsx";
+export * from "./ModelProperty.jsx";
diff --git a/samples/client-emitter/src/context/api.ts b/samples/client-emitter/src/context/api.ts
new file mode 100644
index 0000000..12e5455
--- /dev/null
+++ b/samples/client-emitter/src/context/api.ts
@@ -0,0 +1,27 @@
+import { createContext, useContext } from "@alloy-js/core";
+import { RestApi, RestApiModel, RestApiModelReference } from "../schema.js";
+
+interface ApiContext {
+  schema: RestApi;
+  resolveReference: (ref: RestApiModelReference) => RestApiModel | undefined;
+}
+export const ApiContext = createContext<ApiContext>();
+
+export function useApi(): ApiContext {
+  return useContext(ApiContext)!;
+}
+
+export function createApiContext(schema: RestApi): ApiContext {
+  return {
+    schema,
+    resolveReference(node) {
+      const model = schema.models.find((v) => v.name === node.ref);
+
+      if (!model) {
+        throw new Error(`Unresolved reference ${node.ref}`);
+      }
+
+      return model;
+    },
+  };
+}
diff --git a/samples/client-emitter/src/index.tsx b/samples/client-emitter/src/index.tsx
new file mode 100644
index 0000000..3f768b4
--- /dev/null
+++ b/samples/client-emitter/src/index.tsx
@@ -0,0 +1,29 @@
+import { mapJoin, Output, render, writeOutput } from "@alloy-js/core";
+import * as ts from "@alloy-js/typescript";
+
+import { Client } from "./components/Client.jsx";
+import { Model } from "./components/Model.jsx";
+import { ApiContext, createApiContext } from "./context/api.js";
+import { api } from "./schema.js";
+
+const modelDecls = mapJoin(api.models, (model) => <Model model={model} />);
+
+const namePolicy = ts.createTSNamePolicy();
+
+const output = render(
+  <Output namePolicy={namePolicy}>
+    <ApiContext.Provider value={createApiContext(api)}>
+      <ts.PackageDirectory name={`${api.name}-client`} version="1.0.0">
+        <ts.SourceFile path="models.ts">
+          {modelDecls}
+        </ts.SourceFile>
+        <ts.SourceFile path="client.ts">
+          <Client />
+        </ts.SourceFile>
+        <ts.BarrelFile export />
+      </ts.PackageDirectory>
+    </ApiContext.Provider>
+  </Output>,
+);
+
+writeOutput(output, "./alloy-output");
diff --git a/samples/client-emitter/src/schema.ts b/samples/client-emitter/src/schema.ts
new file mode 100644
index 0000000..e1c26b5
--- /dev/null
+++ b/samples/client-emitter/src/schema.ts
@@ -0,0 +1,76 @@
+export interface RestApi {
+  name: string;
+  operations: RestApiOperation[];
+  models: RestApiModel[];
+}
+
+export interface RestApiOperation {
+  name: string;
+  endpoint: string;
+  verb: "get" | "post";
+  requestBody?: RestApiModelReference;
+  responseBody?: RestApiModelReference;
+}
+
+export interface RestApiModelReference {
+  ref: string;
+  array?: boolean;
+}
+
+export interface RestApiModel {
+  name: string;
+  properties: RestApiModelProperty[];
+}
+
+export interface RestApiModelProperty {
+  name: string;
+  type: RestApiModel | RestApiModelReference | "string" | "number" | "boolean";
+}
+
+export const api: RestApi = {
+  name: "Petstore",
+  operations: [
+    {
+      name: "create_pet",
+      verb: "post",
+      endpoint: "/pets",
+      requestBody: {
+        ref: "Pet",
+      },
+      responseBody: {
+        ref: "Pet",
+      },
+    },
+    {
+      name: "list_pets",
+      verb: "get",
+      endpoint: "/pets",
+      responseBody: {
+        ref: "Pet",
+        array: true,
+      },
+    },
+    {
+      name: "get_pet",
+      verb: "get",
+      endpoint: "/pets/:id",
+      responseBody: {
+        ref: "Pet",
+      },
+    },
+  ],
+  models: [
+    {
+      name: "Pet",
+      properties: [
+        { name: "name", type: "string" },
+        { name: "age", type: "number" },
+        { name: "favoriteToy", type: { ref: "Toy" } },
+      ],
+    },
+    {
+      name: "Toy",
+      properties: [{ name: "name", type: "string" }],
+    },
+  ],
+};
diff --git a/samples/client-emitter/tsconfig.json b/samples/client-emitter/tsconfig.json
new file mode 100644
index 0000000..ec994c2
--- /dev/null
+++ b/samples/client-emitter/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "emitDeclarationOnly": true,
+    "declaration": true,
+    "outDir": "dist"
+  },
+  "references": [
+    { "path": "../../packages/core" },
+    { "path": "../../packages/typescript" }
+  ],
+  "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"],
+  "exclude": ["node_modules", "dist"]
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 839fb4d..982888d 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -13,5 +13,13 @@
     "jsx": "preserve",
     "composite": true,
     "incremental": true
-  }
+  },
+  "references": [
+    { "path": "packages/core" },
+    { "path": "packages/typescript" },
+    { "path": "packages/prettier-plugin-alloy" },
+    { "path": "packages/java" },
+    { "path": "samples/client-emitter" },
+    { "path": "samples/basic-project" }
+  ]
 }
diff --git a/tsconfig.ws.json b/tsconfig.ws.json
index 9c18992..847a6ea 100644
--- a/tsconfig.ws.json
+++ b/tsconfig.ws.json
@@ -4,6 +4,8 @@
     { "path": "packages/core" },
     { "path": "packages/typescript" },
     { "path": "packages/prettier-plugin-alloy" },
-    { "path": "packages/java" }
+    { "path": "packages/java" },
+    { "path": "samples/client-emitter" },
+    { "path": "samples/basic-project" }
   ]
 }


Test patch:
diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts
index 257178f..9657b37 100644
--- a/packages/core/test/symbols.test.ts
+++ b/packages/core/test/symbols.test.ts
@@ -1,5 +1,13 @@
-import { expect, it } from "vitest";
-import { createOutputBinder } from "../src/binder.js";
+import { describe, expect, it } from "vitest";
+import {
+  Binder,
+  createOutputBinder,
+  OutputScope,
+  OutputScopeFlags,
+  OutputSymbol,
+  OutputSymbolFlags,
+} from "../src/binder.js";
+import { Refkey, refkey } from "../src/refkey.js";
 
 it("works", () => {
   const binder = createOutputBinder();
@@ -44,3 +52,355 @@ it("resolves symbol conflicts", () => {
 
   expect(s2.name).toEqual("sym_2");
 });
+
+type ScopeRecords = Record<string, ScopeDescriptor>;
+type SymbolRecords = Record<string, SymbolDescriptor>;
+
+interface ScopeDescriptor {
+  flags?: OutputScopeFlags;
+  scopes?: ScopeRecords;
+  symbols: SymbolRecords;
+}
+
+interface SymbolDescriptor {
+  refkey?: Refkey;
+  flags?: OutputSymbolFlags;
+  instanceMembers?: SymbolRecords;
+  staticMembers?: SymbolRecords;
+}
+
+interface ScopeTreeResult {
+  symbols: Record<string, OutputSymbol>;
+  scopes: Record<string, OutputScope>;
+}
+function createScopeTree(binder: Binder, tree: ScopeRecords): ScopeTreeResult {
+  const createdItems: ScopeTreeResult = {
+    symbols: {},
+    scopes: {},
+  };
+
+  for (const [name, desc] of Object.entries(tree)) {
+    createScope(name, desc);
+  }
+
+  return createdItems;
+
+  function createScope(
+    name: string,
+    descriptor: ScopeDescriptor,
+    parent = binder.globalScope,
+  ) {
+    const scope = binder.createScope({
+      kind: "useless",
+      name,
+      parent,
+      flags: descriptor.flags ?? OutputScopeFlags.None,
+    });
+
+    createdItems.scopes[name] = scope;
+
+    for (const [name, desc] of Object.entries(descriptor.symbols)) {
+      createSymbol(name, desc, scope);
+    }
+
+    for (const [name, desc] of Object.entries(descriptor.scopes ?? {})) {
+      createScope(name, desc, scope);
+    }
+  }
+
+  function createSymbol(
+    name: string,
+    descriptor: SymbolDescriptor,
+    parent: OutputScope,
+  ) {
+    const symbol = binder.createSymbol({
+      name,
+      scope: parent,
+      refkey: descriptor.refkey ?? refkey(),
+      flags: descriptor.flags ?? OutputSymbolFlags.None,
+    });
+
+    createdItems.symbols[name] = symbol;
+
+    if (descriptor.instanceMembers) {
+      for (const [name, desc] of Object.entries(descriptor.instanceMembers)) {
+        createSymbol(name, desc, symbol.instanceMemberScope!);
+      }
+    }
+
+    if (descriptor.staticMembers) {
+      for (const [name, desc] of Object.entries(descriptor.staticMembers)) {
+        createSymbol(name, desc, symbol.staticMemberScope!);
+      }
+    }
+  }
+}
+describe("static members", () => {
+  it("resolves static symbols", () => {
+    const binder = createOutputBinder();
+    const {
+      scopes: { root },
+      symbols: { root: rootSym, static: staticSym },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          root: {
+            flags:
+              OutputSymbolFlags.InstanceMemberContainer |
+              OutputSymbolFlags.StaticMemberContainer,
+            staticMembers: {
+              static: {
+                flags: OutputSymbolFlags.StaticMember,
+              },
+            },
+          },
+        },
+      },
+    });
+
+    const resolution = binder.resolveDeclarationByKey(
+      root,
+      undefined,
+      staticSym.refkey,
+    );
+    expect(resolution.value).toBeDefined();
+    const { commonScope, pathUp, pathDown, targetDeclaration, memberPath } =
+      resolution.value!;
+    expect(commonScope).toBe(root);
+    expect(targetDeclaration).toBe(rootSym);
+    expect(pathDown).toEqual([]);
+    expect(pathUp).toEqual([]);
+    expect(memberPath!.map((s) => s.name)).toEqual(["root", "static"]);
+  });
+
+  it("resolves deeply nested static symbols", () => {
+    const binder = createOutputBinder();
+    const {
+      scopes: { root },
+      symbols: { root: rootSym, nested_static },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          root: {
+            flags:
+              OutputSymbolFlags.InstanceMemberContainer |
+              OutputSymbolFlags.StaticMemberContainer,
+            staticMembers: {
+              static: {
+                flags:
+                  OutputSymbolFlags.StaticMember |
+                  OutputSymbolFlags.StaticMemberContainer,
+                staticMembers: {
+                  nested_static: {
+                    flags: OutputSymbolFlags.StaticMember,
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+    });
+
+    const resolution = binder.resolveDeclarationByKey(
+      root,
+      undefined,
+      nested_static.refkey,
+    );
+    expect(resolution.value).toBeDefined();
+    const { commonScope, pathUp, pathDown, targetDeclaration, memberPath } =
+      resolution.value!;
+    expect(commonScope).toBe(root);
+    expect(targetDeclaration).toBe(rootSym);
+    expect(pathDown).toEqual([]);
+    expect(pathUp).toEqual([]);
+    expect(memberPath!.map((s) => s.name)).toEqual([
+      "root",
+      "static",
+      "nested_static",
+    ]);
+  });
+
+  it("resolves static symbols lazily", () => {
+    const staticSymRefkey = refkey();
+
+    const binder = createOutputBinder();
+    const {
+      scopes: { root },
+      symbols: { root: rootSym },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          root: {
+            flags:
+              OutputSymbolFlags.InstanceMemberContainer |
+              OutputSymbolFlags.StaticMemberContainer,
+          },
+        },
+      },
+    });
+
+    const resolution = binder.resolveDeclarationByKey(
+      root,
+      undefined,
+      staticSymRefkey,
+    );
+    expect(resolution.value).toBeUndefined();
+    binder.createSymbol({
+      name: "static",
+      scope: rootSym.staticMemberScope!,
+      refkey: staticSymRefkey,
+      flags: OutputSymbolFlags.StaticMember,
+    });
+
+    expect(resolution.value).toBeDefined();
+    const { commonScope, pathUp, pathDown, targetDeclaration, memberPath } =
+      resolution.value!;
+    expect(commonScope).toBe(root);
+    expect(targetDeclaration).toBe(rootSym);
+    expect(pathDown).toEqual([]);
+    expect(pathUp).toEqual([]);
+    expect(memberPath!.map((s) => s.name)).toEqual(["root", "static"]);
+  });
+});
+
+describe("instance members", () => {
+  it("resolves", () => {
+    const binder = createOutputBinder();
+    const {
+      symbols: { root: rootSym, instance },
+    } = createScopeTree(binder, {
+      root: {
+        symbols: {
+          root: {
+            flags: OutputSymbolFlags.InstanceMemberContainer,
+            instanceMembers: {
+              instance: {
+                flags: OutputSymbolFlags.InstanceMember,
+              },
+            },
+          },
+        },
+      },
+    });
+
+    const resolution = binder.resolveDeclarationByKey(
+      undefined,
+      rootSym.instanceMemberScope!,
+      instance.refkey,
+    );
+    expect(resolution.value).toBeDefined();
+    const { commonScope, pathUp, pathDown, targetDeclaration, memberPath } =
+      resolution.value!;
+
+    expect(commonScope).toBe(rootSym.instanceMemberScope);
+    expect(targetDeclaration).toBe(instance);
+    expect(pathDown).toEqual([]);
+    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.refkey),
+    ).toThrow(/Cannot resolve member symbols/);
+  });
+});
+
+describe("instantiating members", () => {
+  it("instantiates static symbols", () => {
+    const binder = createOutputBinder();
+    const {
+      symbols: { rootSymbol, instance, instantiation },
+    } = createScopeTree(binder, {
+      rootScope: {
+        symbols: {
+          rootSymbol: {
+            flags: OutputSymbolFlags.InstanceMemberContainer,
+            instanceMembers: {
+              instance: {
+                flags: OutputSymbolFlags.StaticMember,
+              },
+            },
+          },
+          instantiation: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(rootSymbol, instantiation);
+    expect(
+      instantiation.flags & OutputSymbolFlags.InstanceMemberContainer,
+    ).toBeTruthy();
+    expect(instantiation.instanceMemberScope).toBeDefined();
+    const expectedRefkey = refkey(instantiation.refkey, instance.refkey);
+    expect(
+      instantiation.instanceMemberScope!.symbolsByRefkey.get(expectedRefkey),
+    ).toBeDefined();
+  });
+
+  it("instantiates static symbols that are added after the instantiation", () => {
+    const binder = createOutputBinder();
+    const {
+      symbols: { rootSymbol, instance, instantiation },
+    } = createScopeTree(binder, {
+      rootScope: {
+        symbols: {
+          rootSymbol: {
+            flags: OutputSymbolFlags.InstanceMemberContainer,
+            instanceMembers: {
+              instance: {
+                flags: OutputSymbolFlags.StaticMember,
+              },
+            },
+          },
+          instantiation: {},
+        },
+      },
+    });
+
+    binder.instantiateSymbolInto(rootSymbol, instantiation);
+    expect(
+      instantiation.flags & OutputSymbolFlags.InstanceMemberContainer,
+    ).toBeTruthy();
+    expect(instantiation.instanceMemberScope).toBeDefined();
+    const expectedRefkey = refkey(instantiation.refkey, instance.refkey);
+    expect(
+      instantiation.instanceMemberScope!.symbolsByRefkey.get(expectedRefkey),
+    ).toBeDefined();
+
+    const newInstanceMemberRefkey = refkey();
+    binder.createSymbol({
+      name: "newInstanceMember",
+      scope: rootSymbol.instanceMemberScope!,
+      refkey: newInstanceMemberRefkey,
+      flags: OutputSymbolFlags.InstanceMember,
+    });
+    const newExpectedRefkey = refkey(
+      instantiation.refkey,
+      newInstanceMemberRefkey,
+    );
+    expect(
+      instantiation.instanceMemberScope!.symbolsByRefkey.get(newExpectedRefkey),
+    ).toBeDefined();
+  });
+});
diff --git a/packages/csharp/test/class.test.tsx b/packages/csharp/test/class.test.tsx
index e8f11a1..127f3d8 100644
--- a/packages/csharp/test/class.test.tsx
+++ b/packages/csharp/test/class.test.tsx
@@ -30,7 +30,7 @@ it("declares class with some members", () => {
     {
       public class TestClass
       {
-        public string memberOne;
+        public string MemberOne;
         private int memberTwo;
       }
     }
@@ -58,10 +58,16 @@ it("declares class with some methods", () => {
 });
 
 it("declares class with params and return type", () => {
-  const params = {
-    IntParam: "int",
-    StringParam: "string",
-  };
+  const params = [
+    {
+      name: "IntParam",
+      type: "int",
+    },
+    {
+      name: "StringParam",
+      type: "string",
+    },
+  ];
   const res = utils.toSourceText(
     <csharp.Class accessModifier='public' name="TestClass">
       <csharp.ClassMethod accessModifier="public" name="MethodOne" parameters={params} returns="string" />
@@ -81,25 +87,34 @@ it("declares class with params and return type", () => {
 
 it("uses refkeys for members, params, and return type", () => {
   const inputTypeRefkey = core.refkey();
-  const params = {
-    IntParam: "int",
-    BodyParam: inputTypeRefkey,
-  };
+  const testResultTypeRefkey = core.refkey();
+  const enumTypeRefkey = core.refkey();
+
+  const params = [
+    {
+      name: "IntParam",
+      type: "int",
+    },
+    {
+      name: "BodyParam",
+      type: inputTypeRefkey,
+    },
+  ];
 
   const res = core.render(
     <core.Output namePolicy={csharp.createCSharpNamePolicy()}>
       <csharp.Namespace name='TestCode'>
         <csharp.SourceFile path="Test.cs">
-          <csharp.Enum accessModifier='public' name="TestEnum">
+          <csharp.Enum accessModifier='public' name="TestEnum" refkey={enumTypeRefkey}>
             <csharp.EnumMember name="One" />,
             <csharp.EnumMember name="Two" />
           </csharp.Enum>
           <csharp.Class accessModifier="public" name="TestInput" refkey={inputTypeRefkey} />
-          <csharp.Class accessModifier="public" name="TestResult" />
+          <csharp.Class accessModifier="public" name="TestResult" refkey={testResultTypeRefkey} />
           <csharp.Class accessModifier='public' name="TestClass">
-            <csharp.ClassMember accessModifier="private" name="MemberOne" type={core.refkey("TestEnum")} />
-            <csharp.ClassMethod accessModifier="public" name="MethodOne" parameters={params} returns={core.refkey("TestResult")}>
-              return new {core.refkey("TestResult")}();
+            <csharp.ClassMember accessModifier="private" name="MemberOne" type={enumTypeRefkey} />
+            <csharp.ClassMethod accessModifier="public" name="MethodOne" parameters={params} returns={testResultTypeRefkey}>
+              return new {testResultTypeRefkey}();
             </csharp.ClassMethod>
           </csharp.Class>
         </csharp.SourceFile>
@@ -137,8 +152,8 @@ it("declares class with generic parameters", () => {
 
   const res = utils.toSourceText(
     <csharp.Class accessModifier='public' name="TestClass" typeParameters={typeParameters}>
-      <csharp.ClassMember accessModifier="public" name="MemberOne" type={typeParameters.T} />
-      <csharp.ClassMember accessModifier="private" name="MemberTwo" type={typeParameters.U} />
+      <csharp.ClassMember accessModifier="public" name="memberOne" type={typeParameters.T} />
+      <csharp.ClassMember accessModifier="private" name="memberTwo" type={typeParameters.U} />
     </csharp.Class>,
   );
 
@@ -147,7 +162,7 @@ it("declares class with generic parameters", () => {
     {
       public class TestClass<T, U>
       {
-        public T memberOne;
+        public T MemberOne;
         private U memberTwo;
       }
     }
@@ -162,6 +177,73 @@ it("declares class with invalid members", () => {
     </csharp.Class>;
 
   expect(() => utils.toSourceText(decl)).toThrow(
-    "can't define an enum member outside of an enum scope",
+    "can't define an enum member outside of an enum-decl scope",
+  );
+});
+
+it("declares class with constructor", () => {
+  const res = utils.toSourceText(
+    <csharp.Class accessModifier='public' name="TestClass">
+      <csharp.ClassConstructor accessModifier="public" />
+    </csharp.Class>,
   );
+
+  expect(res).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass
+      {
+        public TestClass() {}
+      }
+    }
+  `);
+});
+
+it("declares class with constructor params and assigns values to fields", () => {
+  const thisNameRefkey = core.refkey();
+  const thisSizeRefkey = core.refkey();
+  const paramNameRefkey = core.refkey();
+  const paramSizeRefkey = core.refkey();
+
+  const ctorParams = [
+    {
+      name: "name",
+      type: "string",
+      refkey: paramNameRefkey,
+    },
+    {
+      name: "size",
+      type: "int",
+      refkey: paramSizeRefkey,
+    },
+  ];
+
+  const res = utils.toSourceText(
+    <csharp.Class accessModifier='public' name="TestClass">
+      <csharp.ClassMember accessModifier="private" name="name" type="string" refkey={thisNameRefkey} />
+      <csharp.ClassMember accessModifier="private" name="size" type="int" refkey={thisSizeRefkey} />
+      <csharp.ClassConstructor accessModifier="public" parameters={ctorParams}>
+        {thisNameRefkey} = {paramNameRefkey};
+        {thisSizeRefkey} = {paramSizeRefkey};
+      </csharp.ClassConstructor>
+    </csharp.Class>,
+  );
+
+  // TODO: assignments to members should have this. prefix
+  // e.g. this.name = name;
+  expect(res).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass
+      {
+        private string name;
+        private int size;
+        public TestClass(string name, int size)
+        {
+          name = name;
+          size = size;
+        }
+      }
+    }
+  `);
 });
diff --git a/packages/csharp/test/projectdirectory.test.tsx b/packages/csharp/test/projectdirectory.test.tsx
index ef8517e..345612d 100644
--- a/packages/csharp/test/projectdirectory.test.tsx
+++ b/packages/csharp/test/projectdirectory.test.tsx
@@ -30,6 +30,58 @@ it("defines a project directory file with multiple source files", () => {
       <PropertyGroup>
         <Version>0.1.0</Version>
         <Description>a test project</Description>
+        <TargetFramework>net8.0</TargetFramework>
+      </PropertyGroup>
+    </Project>
+  `);
+
+  const srcDir = projDir.contents[1] as core.OutputDirectory;
+
+  expect(srcDir.contents[0].path).equals("~/projects/TestProject/src/Test1.cs");
+  expect(srcDir.contents[0].contents).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass1;
+    }
+  `);
+
+  expect(srcDir.contents[1].path).equals("~/projects/TestProject/src/Test2.cs");
+  expect(srcDir.contents[1].contents).toBe(coretest.d`
+    namespace TestCode
+    {
+      public class TestClass2;
+    }
+  `);
+});
+
+it("defines a project directory file with multiple source files and a custom TFM", () => {
+  const res = core.render(
+    <core.Output>
+      <csharp.ProjectDirectory name="TestProject" path="~/projects" version="0.1.0" description="a test project" targetFrameworkMoniker="netstandard2.1">
+        <csharp.Namespace name='TestCode'>
+          <csharp.SourceFile path="Test1.cs">
+            <csharp.Class accessModifier='public' name="TestClass1" />
+          </csharp.SourceFile>
+          <csharp.SourceFile path="Test2.cs">
+            <csharp.Class accessModifier='public' name="TestClass2" />
+          </csharp.SourceFile>
+        </csharp.Namespace>
+      </csharp.ProjectDirectory>
+    </core.Output>,
+  );
+
+  const projDir = res.contents[0] as core.OutputDirectory;
+
+  expect(projDir.path).equals("~/projects/TestProject");
+  expect(projDir.contents[0].path).equals(
+    "~/projects/TestProject/TestProject.csproj",
+  );
+  expect(projDir.contents[0].contents).toBe(coretest.d`
+    <Project Sdk="Microsoft.NET.Sdk">
+      <PropertyGroup>
+        <Version>0.1.0</Version>
+        <Description>a test project</Description>
+        <TargetFramework>netstandard2.1</TargetFramework>
       </PropertyGroup>
     </Project>
   `);
diff --git a/packages/csharp/test/using.test.tsx b/packages/csharp/test/using.test.tsx
index 4e4f14b..fa3149f 100644
--- a/packages/csharp/test/using.test.tsx
+++ b/packages/csharp/test/using.test.tsx
@@ -36,28 +36,34 @@ it("uses multiple namespaces", () => {
 
 it("adds using statement across namespaces", () => {
   const inputTypeRefkey = core.refkey();
-  const params = {
-    BodyParam: inputTypeRefkey,
-  };
+  const outputTypeRefkey = core.refkey();
+  const twoValRefkey = core.refkey();
+
+  const params = [
+    {
+      name: "BodyParam",
+      type: inputTypeRefkey,
+    },
+  ];
 
   const res = core.render(
     <core.Output namePolicy={csharp.createCSharpNamePolicy()}>
       <csharp.Namespace name='Models'>
         <csharp.SourceFile path="Models.cs">
           <csharp.Class accessModifier='public' name="Input" refkey={inputTypeRefkey} />
-          <csharp.Class accessModifier='public' name="Output" />
+          <csharp.Class accessModifier='public' name="Output" refkey={outputTypeRefkey} />
           <csharp.Enum accessModifier='public' name="TestEnum">
             <csharp.EnumMember name="One" />,
-            <csharp.EnumMember name="Two" />
+            <csharp.EnumMember name="Two" refkey={twoValRefkey} />
           </csharp.Enum>
         </csharp.SourceFile>
       </csharp.Namespace>
       <csharp.Namespace name='Client'>
         <csharp.SourceFile path="Client.cs">
           <csharp.Class accessModifier='public' name="Client">
-            <csharp.ClassMethod accessModifier="public" name="MethodOne" parameters={params} returns={core.refkey("Output")} />
+            <csharp.ClassMethod accessModifier="public" name="MethodOne" parameters={params} returns={outputTypeRefkey} />
           </csharp.Class>
-          {core.refkey("Two")};
+          {twoValRefkey};
         </csharp.SourceFile>
       </csharp.Namespace>
     </core.Output>,
diff --git a/packages/typescript/test/class.test.tsx b/packages/typescript/test/class.test.tsx
new file mode 100644
index 0000000..1ac09d2
--- /dev/null
+++ b/packages/typescript/test/class.test.tsx
@@ -0,0 +1,181 @@
+import { refkey } from "@alloy-js/core";
+import "@alloy-js/core/testing";
+import { d } from "@alloy-js/core/testing";
+import { describe, expect, it } from "vitest";
+import * as ts from "../src/components/index.js";
+import { toSourceText } from "./utils.js";
+
+it("declares classes", () => {
+  const res = toSourceText(<ts.ClassDeclaration name="Foo" />);
+  expect(res).toEqual(d`
+    class Foo {
+      
+    }
+  `);
+});
+
+it("accepts export and default", () => {
+  const res = toSourceText(<ts.ClassDeclaration name="Foo" export default />);
+  expect(res).toEqual(d`
+    export default class Foo {
+      
+    }
+  `);
+});
+
+it("creates extends", () => {
+  const res = toSourceText(<ts.ClassDeclaration name="Foo" extends="string" />);
+
+  expect(res).toEqual(d`
+    class Foo extends string {
+      
+    }
+  `);
+});
+
+describe("instance members", () => {
+  it("can be created", () => {
+    const res = toSourceText(
+      <ts.ClassDeclaration name="Foo">
+        <ts.ClassField name="one" />
+        <ts.ClassField name="two" public />
+        <ts.ClassField name="three" private />
+        <ts.ClassField name="four" protected />
+        <ts.ClassField name="five" type="number" />
+        <ts.ClassField name="six" type="number">12</ts.ClassField>
+        <ts.ClassField name="seven" jsPrivate />
+      </ts.ClassDeclaration>,
+    );
+
+    expect(res).toEqual(d`
+      class Foo {
+        one;
+        public two;
+        private three;
+        protected four;
+        five: number;
+        six: number = 12;
+        #seven;
+      }
+    `);
+  });
+
+  it("can be referenced", () => {
+    const one = refkey();
+    const res = toSourceText(
+      <ts.ClassDeclaration name="Foo">
+        <ts.ClassField name="one" refkey={one}>1</ts.ClassField>
+        <ts.ClassField name="two">{one} + 1</ts.ClassField>
+      </ts.ClassDeclaration>,
+    );
+
+    expect(res).toEqual(d`
+      class Foo {
+        one = 1;
+        two = this.one + 1;
+      }
+    `);
+  });
+
+  it("cannot be referenced outside the class", () => {
+    const one = refkey();
+    expect(() => {
+      toSourceText(
+        <>
+          <ts.ClassDeclaration name="Foo">
+            <ts.ClassField name="one" refkey={one}>1</ts.ClassField>
+          </ts.ClassDeclaration>
+          {one}
+        </>,
+      );
+    }).toThrow(/Cannot resolve member symbols/);
+  });
+});
+
+describe("static members", () => {
+  it("renders", () => {
+    const one = refkey();
+    const res = toSourceText(
+      <ts.ClassDeclaration name="Foo">
+        <ts.ClassField static name="one" refkey={one}>1</ts.ClassField>
+      </ts.ClassDeclaration>,
+    );
+
+    expect(res).toBe(d`
+      class Foo {
+        static one = 1;
+      }
+    `);
+  });
+
+  it("can be referenced", () => {
+    const one = refkey();
+    const res = toSourceText(
+      <>
+        <ts.ClassDeclaration name="Foo">
+          <ts.ClassField static name="one" refkey={one}>1</ts.ClassField>
+        </ts.ClassDeclaration>
+        {one};
+      </>,
+    );
+
+    expect(res).toBe(d`
+      class Foo {
+        static one = 1;
+      }
+      Foo.one;
+    `);
+  });
+});
+
+describe("instance methods", () => {
+  it("renders", () => {
+    const one = refkey();
+    const a = refkey();
+    const b = refkey();
+
+    const res = toSourceText(
+      <ts.ClassDeclaration name="Foo">
+        <ts.ClassMethod name="one" refkey={one} />
+        <ts.ClassMethod name="two" public />
+        <ts.ClassMethod name="three" private />
+        <ts.ClassMethod name="four" protected />
+        <ts.ClassMethod name="five" parameters={{a: "string", b: "string"}} />
+        <ts.ClassMethod
+          name="six"
+          parameters={{a: {type: "number", refkey: a }, b: {type: "number", refkey: b }}}
+          returnType="number"
+        >
+          {one} = {a} + {b};
+        </ts.ClassMethod>
+        <ts.ClassMethod name="seven" jsPrivate />
+      </ts.ClassDeclaration>,
+    );
+
+    expect(res).toEqual(d`
+      class Foo {
+        one() {
+          
+        }
+        public two() {
+          
+        }
+        private three() {
+          
+        }
+        protected four() {
+          
+        }
+        five(a: string, b: string) {
+          
+        }
+        six(a: number, b: number): number {
+          this.one = a + b;
+        }
+        #seven() {
+          
+        }
+      }
+    `);
+  });
+});
diff --git a/samples/client-emitter/vitest.config.ts b/samples/client-emitter/vitest.config.ts
new file mode 100644
index 0000000..ed2f543
--- /dev/null
+++ b/samples/client-emitter/vitest.config.ts
@@ -0,0 +1,22 @@
+import { babel } from "@rollup/plugin-babel";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    include: ["test/**/*.ts", "test/**/*.tsx"],
+    exclude: ["test/**/*.util.ts", "test/**/*.d.ts"],
+  },
+  esbuild: {
+    jsx: "preserve",
+    sourcemap: "both",
+  },
+  plugins: [
+    babel({
+      inputSourceMap: true as any,
+      sourceMaps: "both",
+      babelHelpers: "bundled",
+      extensions: [".ts", ".tsx"],
+      presets: ["@babel/preset-typescript", "babel-preset-alloy"],
+    }),
+  ],
+});