Skip to content

Conversation

@seefeldb
Copy link
Contributor

@seefeldb seefeldb commented Oct 29, 2025

Unify Type System: IKeyable Interface and Cell Type Architecture

Note: This carries over some duplication between OpaqueRef and Cells as separate types. And there is duplicative use of symbols to brand types. This will be fixed in a future PR.

Overview

This PR fundamentally restructures the type system to use a Higher-Kinded Type (HKT) pattern for the IKeyable interface, replacing the previous scattered type definitions with a unified, composable architecture. This change improves type safety, eliminates circular dependencies, and provides a foundation for better variance control across the codebase.

Major Changes

1. Introduced HKT-based IKeyable<T, Wrap> Interface (packages/api/index.ts)

  • Replaced multiple ad-hoc .key() method signatures with a single, reusable IKeyable<T, Wrap> interface
  • Uses lightweight Higher-Kinded Types (HKT) to parameterize the return type wrapper (e.g., Cell<T>, OpaqueCell<T>, ReadonlyCell<T>)
  • Properly handles covariance with out T modifier and variance guards (unknown extends K)
  • Automatically unwraps nested BrandedCell types to prevent accumulation (e.g., Cell<Cell<T>> becomes Cell<T>)
  • Includes comprehensive JSDoc with variance explanation and examples

Key type:

export interface IKeyable<out T, Wrap extends HKT> {
  key<K extends PropertyKey>(
    this: IsThisObject, // only allow on object types
    valueKey: K,
  ): KeyResultType<T, K, Wrap>;
}

2. Unified Cell Architecture with Capability Interfaces

Restructured the entire cell type system into composable capability interfaces:

  • IAnyCell<T> - Base marker interface
  • IReadable<T> - Read operations (.get())
  • IWritable<T> - Write operations (.set(), .update(), .push())
  • IStreamable<T> - Event sending (.send())
  • IEquatable - Equality checks (.equals())
  • IKeyable<T, Wrap> - Property access (.key())
  • IResolvable<T, C> - Cell resolution (.resolveAsCell())
  • IDerivable<T> - Derivation operations (.map(), .mapWithPattern())
  • IOpaquable<T> - Legacy opaque operations (deprecated methods)

3. Brand-Based Cell Variants

Introduced a brand system using BrandedCell<T, Brand> to distinguish cell types at compile-time:

  • Cell<T> - Full cell with read/write/stream capabilities (brand: "cell")
  • OpaqueCell<T> - Opaque reference for keying and derivation only (brand: "opaque")
  • Stream<T> - Stream-only for event sending (brand: "stream")
  • ReadonlyCell<T> - Read-only variant (brand: "readonly")
  • WriteonlyCell<T> - Write-only variant (brand: "writeonly")
  • ComparableCell<T> - Equality checks only (brand: "comparable")

Each variant composes only the capability interfaces it needs, preventing misuse.

4. Replaced Cellify<T> with AnyCellWrapping<T>

  • Renamed for clarity - this type is specifically for write operations
  • Improved handling of BrandedCell unwrapping in nested structures
  • Added support for ID and ID_FIELD metadata symbols
  • Used consistently in .set(), .update(), .push(), and .send() methods

5. Unified CellLike and Opaque Semantics

  • CellLike<T> - Simplified to just AnyCell<T> (top level must be a cell)
  • Opaque<T> - Accepts T OR any cell wrapping T, recursively at any level
  • Clarified the distinction: CellLike requires cells, Opaque accepts plain or wrapped values

6. Module Augmentation Refactor (packages/runner/src/cell.ts)

  • Moved from augmenting concrete types to augmenting capability interfaces
  • Augments IAnyCell<T>, IWritable<T>, IStreamable<T> with runtime-specific methods
  • Cleaner separation between public API (@commontools/api) and runtime implementation

7. Type Utilities

  • UnwrapCell<T> - Recursively unwraps BrandedCell types while preserving any and unknown
  • KeyResultType<T, K, Wrap> - Implements the .key() return type logic with all variance guards

Benefits

  1. Type Safety: Stronger compile-time guarantees prevent misuse of cell methods
  2. Composability: Capability interfaces can be mixed and matched for new cell variants
  3. Variance Control: Proper covariance handling prevents unsound type assignments
  4. No Circular Dependencies: UnwrapCell and BrandedCell break previous circular type issues
  5. Better Inference: .key() now correctly infers nested property types across all cell variants
  6. Maintainability: Single source of truth for .key() behavior via IKeyable

Migration Notes

  • Renamed: Cellify<T> ‚Üí AnyCellWrapping<T> (semantically the same for write operations)
  • Renamed: OpaqueRefMethods<T> ‚Üí IOpaqueCell<T> (interface for opaque cell capabilities)
  • New: All cell types now extend BrandedCell<T, Brand> for compile-time differentiation
  • Deprecated: Legacy methods in IOpaquable are marked as deprecated

Testing

  • All existing tests pass with updated type annotations
  • Added new schema tests for UnwrapCell and nested cell handling
  • Integration tests verify .key() works correctly across all cell variants

Summary by cubic

Unifies the Cell type system using brands and capability interfaces, and adds an HKT-based key() that returns correctly wrapped types. This improves type safety, simplifies APIs, and fixes inference across runner, CLI, and UI.

  • Refactors

    • Added BrandedCell and capability interfaces (Readable, Writable, Streamable, Equatable, Derivable, Keyable, Resolvable).
    • Implemented IKeyable<T, Wrap> with HKT wrappers (Cell, ReadonlyCell, WriteonlyCell, ComparableCell); key() now unwraps brands and preserves variance.
    • Introduced AnyCell, OpaqueCell, IOpaqueCell, and IOpaquable; OpaqueRef is now an OpaqueCell-based proxy.
    • Replaced Cellify with AnyCellWrapping for set/update/push/send.
    • Renamed isOpaqueRef to isOpaqueCell; parseLink now accepts AnyCell and correctly handles stream bases.
    • Updated RegularCell/StreamCell to use AnyCellWrapping and improved schema tracking in key().
    • Tightened types in runner, CLI, UI, and tests; added explicit casts where inference is limited.
    • Added a TypeScript profiling harness under packages/api/perf to measure type-checking cost of API types.
    • Updated schema-generator and ts-transformers to detect CELL_BRAND and treat OpaqueCell correctly in generation.
    • Schema generator no longer emits ID/ID_FIELD in inferred schema output.
  • Migration

    • Rename isOpaqueRef(...) to isOpaqueCell(...).
    • FetchDataFunction/StreamDataFunction now return OpaqueRef<{ pending; result; error }>.
    • key() may need explicit casts when selecting streams (e.g., const s = cell.key("handler") as unknown as Stream).
    • Use key(index) for array elements on OpaqueRef proxies instead of bracket access.
    • Update write methods to pass AnyCellWrapping (set, update, push, send).
    • OpaqueRef methods are exposed via IOpaqueCell/IOpaquable; use export()/connect() from these interfaces.

Written for commit 16e2226. Summary will update automatically on new commits.

@seefeldb seefeldb requested a review from jsantell October 29, 2025 22:52
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 27 files

Prompt for AI agents (all 3 issues)

Understand the root cause of the following 3 issues and fix them.


<file name="packages/api/index.ts">

<violation number="1" location="packages/api/index.ts:34">
`BrandedCell` dropped the underlying value type, so helpers like `UnwrapCell` now infer `unknown`, breaking key() and wrapping typings.</violation>

<violation number="2" location="packages/api/index.ts:62">
`push` now widens the non-array branch to `any[]`, so non-array cells can call `.push(...)` without a compile-time error, risking runtime failures.</violation>
</file>

<file name="packages/runner/src/link-utils.ts">

<violation number="1" location="packages/runner/src/link-utils.ts:250">
This branch has the same issue: `isAnyCell` can match streams, but streams do not have `entityId`, so `toURI` receives `undefined` and throws when a stream is supplied as the base. Pull the id from `getAsNormalizedFullLink()` (which streams implement) instead.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

// If no id provided, use base cell's document
if (!id && base) {
id = isCell(base) ? toURI(base.entityId) : base.id;
id = isAnyCell(base) ? toURI(base.entityId) : base.id;
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch has the same issue: isAnyCell can match streams, but streams do not have entityId, so toURI receives undefined and throws when a stream is supplied as the base. Pull the id from getAsNormalizedFullLink() (which streams implement) instead.

Prompt for AI agents
Address the following comment on packages/runner/src/link-utils.ts at line 250:

<comment>This branch has the same issue: `isAnyCell` can match streams, but streams do not have `entityId`, so `toURI` receives `undefined` and throws when a stream is supplied as the base. Pull the id from `getAsNormalizedFullLink()` (which streams implement) instead.</comment>

<file context>
@@ -247,7 +247,7 @@ export function parseLink(
     // If no id provided, use base cell&#39;s document
     if (!id &amp;&amp; base) {
-      id = isCell(base) ? toURI(base.entityId) : base.id;
+      id = isAnyCell(base) ? toURI(base.entityId) : base.id;
     }
 
</file context>
Suggested change
id = isAnyCell(base) ? toURI(base.entityId) : base.id;
id = isAnyCell(base) ? base.getAsNormalizedFullLink().id : base.id;
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 17 files

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed changes from recent commits (found 1 issue).

1 issue found across 1 file

Prompt for AI agents (all 1 issues)

Understand the root cause of the following 1 issues and fix them.


<file name="packages/api/index.ts">

<violation number="1" location="packages/api/index.ts:151">
The new KeyResultType no longer unwraps nested branded cells, so key(&quot;nested&quot;) on Cell&lt;{ nested: Cell&lt;number&gt; }&gt; now infers Cell&lt;Cell&lt;number&gt;&gt;, breaking the documented guarantee that nested cells don’t accumulate wrappers.</violation>
</file>

React with 👍 or 👎 to teach cubic. Mention @cubic-dev-ai to give feedback, ask questions, or re-run the review.

Copy link
Collaborator

@jsantell jsantell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't yet completely grok all the new types, but the composable feature approach to cell types LGTM


## Prerequisites

The commands below assume you are inside the repo root (`labs-secondary`) and
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secondary?


The commands below assume you are inside the repo root (`labs-secondary`) and
that the vendored TypeScript binary at
`node_modules/.deno/typescript@5.8.3/node_modules/typescript/bin/tsc` is
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably in your local deno cache, there are no node_modules in our workspace

like so:

```bash
node -e 'const trace=require("./packages/api/perf/traces/ikeyable-cell/trace.json");\
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use node in our codebase

*/
export interface IDerivable<T> {
map<S>(
this: IsThisObject,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice specialization work here

| BrandedCell<Array<unknown>>
| BrandedCell<Array<any>>
| BrandedCell<Record<string, unknown | any>>
| BrandedCell<unknown>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could "inherit" from IsThisArray:

type IsThisObject = 
  | BrandedCell<JSONObject>
  | BrandedCell<Record<string, unknown | any>>
  | IsThisArray

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, done

| BrandedCell<JSONArray | JSONObject>
| BrandedCell<Array<unknown>>
| BrandedCell<Array<any>>
| BrandedCell<Record<string, unknown | any>>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the result of unknown | any?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just unknown seems to work, so removed the any

* Used for type-level operations like unwrapping nested cells without
* creating circular dependencies.
*/
export type BrandedCell<T, Brand extends string = string> = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the meaning of the brand value (second generic)? I see "opaque" and "comparable" etc., could these be constrained to some subset? Or are all strings allowed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it so that Cell<T> & Opaque<T> isn't allowed. We could restrict it further, but in the interest of keeping types simple I didn't see the upside, this is very internal type.

@seefeldb seefeldb requested a review from mathpirate October 30, 2025 21:08
* Overloads just help make fields non-optional that can be guaranteed to exist
* in various combinations.
*/
export function parseLink(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseLink was updated to accept AnyCell, but when the base is actually a stream we still try to read entityId and blow up.

I made a separate branch https://github.com/commontoolsinc/labs/tree/dx1/unify-types-stream-base-fix where I added a targeted regression test (stream-base-parse-link.test.ts) that reproduces the failure, then taught parseLink to normalize the base via getAsNormalizedFullLink() before dereferencing id/space. With that change the test passes, without it the test fails.

Unfortunately that branch has typechecking errors and I can't tell whether they're coming from my change or not... leaving this comment so you can help me figure it out Berni

seefeldb and others added 3 commits November 3, 2025 08:47
Implements brand-based type system for unified cell types as specified
in docs/specs/recipe-construction/rollout-plan.md task 2.

## Changes

### packages/api/index.ts
- Add CELL_BRAND symbol and CellBrand type with capability flags
- Create AnyCell<T, Brand> base type for all cell variants
- Factor out capability interfaces:
  - Readable, Writable, Streamable (I/O operations)
  - Keyable, Resolvable, Equatable (structural operations)
  - Derivable (transformations)
- Define cell types using brands:
  - OpaqueCell (opaque references with .key() and .map())
  - Cell (full read/write/stream interface)
  - Stream (send-only)
  - ComparableCell, ReadonlyCell, WriteonlyCell
- Add OpaqueRefMethods<T> interface for runtime augmentation
- Update OpaqueRef<T> to extend OpaqueCell + OpaqueRefMethods
- Add CellLike<T> and OpaqueLike<T> utility types

### packages/runner/src/builder/types.ts
- Update module augmentation for OpaqueRefMethods
- Add runtime methods: .export(), .setDefault(), .setName(), etc.
- Remove duplicate method definitions (inherited from base)

### packages/runner/src/cell.ts
- Add [CELL_BRAND] property to StreamCell and RegularCell
- Import AnyCell and CELL_BRAND from @commontools/api
- Update push() signature to accept AnyCell<any>

## Results
- Reduced type errors from 51 to 10 (80% reduction)
- Cell and Stream remain interfaces for module augmentation
- Backward compatible with existing code
- Foundation for future cell system refactoring

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Use Omit to exclude methods from OpaqueCell that are redefined in
OpaqueRefMethods, ensuring the OpaqueRefMethods versions take precedence.

This fixes the issue where .key() on OpaqueRef was returning OpaqueCell
instead of OpaqueRef, causing test failures when chaining methods like
.key().export().

## Changes
- Add .key() to OpaqueRefMethods with OpaqueRef return type
- Use Omit<OpaqueCell<T>, keyof OpaqueRefMethods<any>> in OpaqueRef type
- Ensures OpaqueRef.key() returns OpaqueRef<T[K]>, not OpaqueCell<T[K]>

## Results
- Reduced errors from 8 to 2 (75% reduction)
- OpaqueRef.key() now correctly preserves OpaqueRef type
- Total error reduction: 51 → 2 (96% improvement!)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
seefeldb and others added 14 commits November 3, 2025 08:47
Refactors isOpaqueRefType() to clarify detection strategy:
1. Primary: Check CELL_BRAND property (most reliable when accessible)
2. Fallback: Name-based detection for edge cases

The fallback is necessary because in some TypeScript type resolution
scenarios (alias resolutions, interface references), the CELL_BRAND
property isn't directly exposed by TypeScript's type system APIs.

Improvements:
- Extract fallback logic into isCellTypeByName() helper
- Consolidate name checks into isCellTypeName() and containsCellTypeName()
- Add clear documentation explaining why fallback is needed
- Eliminate code duplication

All tests pass (103 steps).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Through systematic testing, discovered that only ONE fallback check is needed:
checking the type reference target symbol name.

Removed unnecessary checks:
- type.getSymbol() - not needed
- type.aliasSymbol - not needed
- resolvesToCommonToolsSymbol() - not needed
- containsCellTypeName() with qualified names - not needed

The minimal fallback that works:
- Check typeRef.target.symbol.getName() matches known cell type names

This reduces code from ~100 lines to ~20 lines while maintaining all
functionality. The fallback is only needed when CELL_BRAND isn't accessible
during certain TypeScript type resolution stages.

All tests pass (103 steps).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Added detailed comments explaining why the fallback is necessary and
when each detection method works.

Key findings from diagnostic investigation:
- CELL_BRAND check: 0 successes across all tests
- Fallback check: 1,244 uses across all tests

The issue: TypeScript's type.getProperty() doesn't expose properties from
generic interface declarations. When we receive TypeReference types
(e.g., OpaqueCell<number>), we're looking at a reference to the generic
interface OpaqueCell<T>, not a fully instantiated type. The CELL_BRAND
property exists in the source, but isn't accessible via the type system API.

The fallback works by checking typeRef.target.symbol.getName() which gives
us the interface name ("OpaqueCell", "Cell", "Stream") without needing to
access the interface's properties.

This is a fundamental limitation of how TypeScript's Compiler API exposes
type information for generic interfaces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Switch the opaque-ref transformer to resolve branded cell types by walking apparent/reference/base types and reading the CELL_BRAND unique symbol. This makes detection resilient to renames and ensures we respect the authoritative metadata exposed by the public API.

Drop the legacy symbol-name fallback that was compensating for the missing brand lookup and rely fully on the brand instead. The new helper handles unions, intersections, and class/interface hierarchies so existing call sites continue to work.

Align the test harness with the real OpaqueRef definition by modeling the brand instead of a stub field. This keeps the analyzer fixtures representative of production behavior while staying intentionally minimal.
- add explicit type to note.tsx
@seefeldb seefeldb merged commit 991fb20 into main Nov 3, 2025
8 checks passed
@seefeldb seefeldb deleted the dx1/unify-types branch November 3, 2025 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants