Skip to content

refactor: replace JsonObject with custom EntityBase type#4

Merged
jakeklassen merged 3 commits into
mainfrom
refactor/entity-base-type
Mar 13, 2026
Merged

refactor: replace JsonObject with custom EntityBase type#4
jakeklassen merged 3 commits into
mainfrom
refactor/entity-base-type

Conversation

@jakeklassen

@jakeklassen jakeklassen commented Feb 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replace type-fest's JsonObject with custom ComponentValue + EntityBase types
  • EntityBase = Record<string, ComponentValue | undefined> — the | undefined makes TypeScript correctly model missing components
  • ComponentValue still prevents functions/symbols/non-serializable values as component data (same safety as JsonObject)
  • Drop type-fest from dependencies — zero runtime dependencies (saves consumers 512KB)
  • Remove @typescript-eslint/no-unnecessary-condition: "off" eslint override — no longer needed since TS now knows property access can be undefined
  • Remove duplicate SafeEntity type from archetype.ts (imports from world.ts instead)

Why

JsonObject's index signature {[Key in string]: JsonValue} tells TypeScript every property access returns JsonValue — never undefined. But at runtime, missing ECS components ARE undefined. This mismatch required an eslint rule override and obscured the type system's understanding of our code.

The new EntityBase type accurately models the runtime behavior while maintaining the same "no functions as components" constraint.

Test plan

  • pnpm --filter objecs build — lint + build passes
  • pnpm --filter objecs test -- --run — all 22 tests pass
  • pnpm lint — full monorepo lint clean
  • pnpm --filter objecs check-exports — all export conditions green

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Updated public type docs to require JSON‑compatible component values (no functions or symbols) and simplified package README wording.
  • Chores
    • Removed an external utility dependency from the package.
    • Re-enabled stricter linting rules for the package.
  • Refactor
    • Tightened public type constraints across APIs to enforce JSON-serializable components.
  • Tests
    • Added type-level tests to validate acceptance/rejection of component shapes.

Replace type-fest's JsonObject with custom ComponentValue + EntityBase
types. EntityBase includes `| undefined` in its index signature so
TypeScript correctly models missing components, eliminating the
no-unnecessary-condition eslint override. ComponentValue still prevents
functions/symbols as component data.

- Drop type-fest dependency (saves consumers 512KB)
- Remove no-unnecessary-condition eslint override (no longer needed)
- Remove duplicate SafeEntity type from archetype.ts
- Zero runtime dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@netlify

netlify Bot commented Feb 23, 2026

Copy link
Copy Markdown

Deploy Preview for objecs ready!

Name Link
🔨 Latest commit 0ac352c
🔍 Latest deploy log https://app.netlify.com/projects/objecs/deploys/699bab0ec6db0d0008934939
😎 Deploy Preview https://deploy-preview-4--objecs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@jakeklassen has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 55 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

Tightened type constraints by introducing exported EntityBase and ComponentValue types and replacing usages of JsonObject with EntityBase across Archetype and World. Removed type-fest dependency and an ESLint rule disable. Added tests enforcing the new EntityBase constraint and updated docs.

Changes

Cohort / File(s) Summary
Type System Foundation
packages/objecs/src/world.ts, packages/objecs/src/archetype.ts
Added exported ComponentValue and EntityBase types. Replaced generic constraints using JsonObject with EntityBase on Archetype, SafeEntity, and World.
Tests
packages/objecs/src/world.test.ts
Imported type EntityBase and added tests validating rejection of function/symbol components and acceptance of JSON-compatible entities under the new EntityBase constraint.
Dependency & Configuration
packages/objecs/package.json, eslint.config.mjs, packages/objecs/README.md
Removed type-fest from package.json. Removed the @typescript-eslint/no-unnecessary-condition disable for packages/objecs/src/**/*.ts in ESLint config. README Feature line updated to drop the type-fest mention.
Documentation
CLAUDE.md
Updated public Entity description to require extending EntityBase, explicitly restricting components to JSON-compatible values and excluding functions/symbols.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I tightened types with a careful hop,
EntityBase now keeps components proper and top,
No functions, no symbols—JSON-only delight,
Docs trimmed, tests added, lint rules set right,
Hop, code, hop—our types are snug and tight! 🎉

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main refactoring: replacing the external JsonObject type from type-fest with a custom EntityBase type, which is the central change across all modified files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/entity-base-type

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/objecs/src/world.ts (1)

302-328: ⚠️ Potential issue | 🟡 Minor

addEntityComponents single-component branch silently falls through when value is undefined.

Line 315: typeof componentOrComponents === 'string' && value !== undefined — if a caller passes a string key with value === undefined (e.g. a runtime slip), execution falls into the else branch and treats the string key as a components map, silently no-oping. The TypeScript overload types value as NonNullable<Entity[Component]>, so this can't happen at compile time, but it's a runtime footgun. Consider a guard or an explicit assertion:

🛡️ Proposed guard
 if (typeof componentOrComponents === "string" && value !== undefined) {
   // `@ts-ignore`
   entity[componentOrComponents] = value;
   changedKeys.push(componentOrComponents as keyof Entity);
-} else {
+} else if (typeof componentOrComponents !== "string") {
   const components = componentOrComponents as Record<string, unknown>;
   for (const key in components) {
     (entity as any)[key] = components[key];
     changedKeys.push(key as keyof Entity);
   }
+} else {
+  // string key with undefined value — no-op (component already absent or value intentionally omitted)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/objecs/src/world.ts` around lines 302 - 328, The single-component
branch in addEntityComponents can silently fall through when
componentOrComponents is a string but value === undefined; add an explicit
runtime guard at the start of that branch to detect this case and throw a clear
Error (or assert) instead of treating the string as a components map—ensure the
guard checks typeof componentOrComponents === "string" and value === undefined,
throws (e.g. "Missing value for component '<key>'"), and leaves changedKeys
handling unchanged so single-component updates still push the key when valid.
🧹 Nitpick comments (1)
packages/objecs/src/world.ts (1)

3-19: Well-structured — mirrors JsonValue/JsonObject from type-fest exactly as intended.

One subtle behaviour worth documenting in the JSDoc: TypeScript 4.2 allows objects with optional properties to assign to index signatures whose value type doesn't include undefined, so { trigger?: string } satisfies { [key: string]: ComponentValue } — but a non-optional property typed as ComponentValue | undefined explicitly would still fail. Since this distinction surprises contributors, a brief note in the ComponentValue JSDoc would prevent confusion.

📝 Suggested JSDoc addition
 /**
  * Allowed component value types. Prevents functions, symbols, and other
  * non-serializable values from being used as component data.
+ *
+ * Nested object properties with the optional modifier (`prop?`) satisfy this
+ * constraint (TypeScript 4.2+). Properties typed as `T | undefined` explicitly
+ * (without `?`) do not — use `prop?: T` instead.
  */
 export type ComponentValue =
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/objecs/src/world.ts` around lines 3 - 19, Add a brief JSDoc note to
the ComponentValue type (and optionally reference EntityBase) explaining the
TypeScript 4.2 nuance: objects with optional properties (e.g., { trigger?:
string }) are assignable to an index signature whose value type does not include
undefined, whereas a property explicitly typed as ComponentValue | undefined is
not the same; update the comment above ComponentValue to call out this
difference so contributors understand why optional props behave differently in
type compatibility checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/objecs/src/world.ts`:
- Around line 302-328: The single-component branch in addEntityComponents can
silently fall through when componentOrComponents is a string but value ===
undefined; add an explicit runtime guard at the start of that branch to detect
this case and throw a clear Error (or assert) instead of treating the string as
a components map—ensure the guard checks typeof componentOrComponents ===
"string" and value === undefined, throws (e.g. "Missing value for component
'<key>'"), and leaves changedKeys handling unchanged so single-component updates
still push the key when valid.

---

Nitpick comments:
In `@packages/objecs/src/world.ts`:
- Around line 3-19: Add a brief JSDoc note to the ComponentValue type (and
optionally reference EntityBase) explaining the TypeScript 4.2 nuance: objects
with optional properties (e.g., { trigger?: string }) are assignable to an index
signature whose value type does not include undefined, whereas a property
explicitly typed as ComponentValue | undefined is not the same; update the
comment above ComponentValue to call out this difference so contributors
understand why optional props behave differently in type compatibility checks.

@jakeklassen jakeklassen force-pushed the refactor/entity-base-type branch from 34dc400 to 0ac352c Compare February 23, 2026 01:19
Verify that functions and symbols are rejected as component values
at compile time, and that valid JSON-compatible types are accepted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/objecs/src/world.test.ts`:
- Line 353: Replace the deprecated assertion: change the expectTypeOf check that
uses toMatchTypeOf to the new toExtend variant; specifically update the
assertion on ValidEntity and EntityBase
(expectTypeOf<ValidEntity>().toMatchTypeOf<EntityBase>()) to use
expectTypeOf<ValidEntity>().toExtend<EntityBase>() so the type-assignability
test uses the supported matcher.

Comment thread packages/objecs/src/world.test.ts Outdated
@jakeklassen

Copy link
Copy Markdown
Owner Author

Response to CodeRabbit review

1. addEntityComponents silent fall-through (potential issue)

Disagree. The TypeScript overloads type value as NonNullable<Entity[Component]>, so passing undefined is a compile-time error. This scenario requires deliberately circumventing the type system. The library targets TypeScript users, and adding a runtime guard on a hot path for something the compiler already prevents isn't worth the overhead.

2. JSDoc note about prop? vs T | undefined (nitpick)

Disagree. This is standard TypeScript behavior (TS 4.2 index signature compatibility), not specific to our types. Every example and test in the library uses the prop?: T pattern naturally. Documenting general language-level nuances in our JSDoc adds noise without helping contributors who already understand TypeScript's optional property semantics.

3. toMatchTypeOftoExtend (actionable)

Already fixed in 0ac352c.

@jakeklassen jakeklassen merged commit 1f4f82a into main Mar 13, 2026
7 checks passed
@jakeklassen jakeklassen deleted the refactor/entity-base-type branch March 13, 2026 11:58
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.

1 participant