From 43346f6f2605d1a99f979811352bc14d140caf8f Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Sun, 22 Feb 2026 19:27:53 -0500 Subject: [PATCH 1/3] refactor: replace JsonObject with custom EntityBase type 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 --- eslint.config.mjs | 5 ----- packages/objecs/README.md | 2 +- packages/objecs/package.json | 4 +--- packages/objecs/src/archetype.ts | 10 ++-------- packages/objecs/src/world.ts | 23 ++++++++++++++++++++--- pnpm-lock.yaml | 4 ---- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4c2b565..ef9ac1c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -49,11 +49,6 @@ export default defineConfig( "Use `entity[prop] !== undefined` instead of Object.hasOwn() in core library. Object.hasOwn is ~10% slower due to static method call overhead. See CLAUDE.md Performance Conventions.", }, ], - // ECS entities use JsonObject index signature ({[Key in string]: JsonValue}) - // so TS thinks every property access returns JsonValue, never undefined. - // At runtime, missing components ARE undefined — this check is the core - // archetype matching mechanism. - "@typescript-eslint/no-unnecessary-condition": "off", }, }, { diff --git a/packages/objecs/README.md b/packages/objecs/README.md index 123c6de..80e777e 100644 --- a/packages/objecs/README.md +++ b/packages/objecs/README.md @@ -9,7 +9,7 @@ A lightweight, type-safe Entity Component System (ECS) for TypeScript game devel - **Simple API** - Entities are plain JavaScript objects, components are just properties - **Type-safe** - Full TypeScript support with inferred types for queries -- **Zero dependencies** (only `type-fest` for utility types) +- **Zero dependencies** - **Small footprint** - ~2KB minified - **Archetype-based queries** - Efficient entity filtering with component matching - **Reactive archetypes** - Queries automatically update when entities change diff --git a/packages/objecs/package.json b/packages/objecs/package.json index 179a7b5..fb9abe6 100644 --- a/packages/objecs/package.json +++ b/packages/objecs/package.json @@ -68,7 +68,5 @@ "vite": "7.3.1", "vitest": "4.0.17" }, - "dependencies": { - "type-fest": "5.4.0" - } + "dependencies": {} } diff --git a/packages/objecs/src/archetype.ts b/packages/objecs/src/archetype.ts index 4dd35cc..83ffc7c 100644 --- a/packages/objecs/src/archetype.ts +++ b/packages/objecs/src/archetype.ts @@ -1,10 +1,4 @@ -import { JsonObject } from "type-fest"; -import { EntityCollection, ReadonlyEntityCollection, World } from "./world.js"; - -type SafeEntity< - Entity extends JsonObject, - Components extends keyof Entity, -> = Entity & Required>; +import { type EntityBase, EntityCollection, type ReadonlyEntityCollection, type SafeEntity, World } from "./world.js"; /** * An archetype is a collection of entities that share the same components. @@ -12,7 +6,7 @@ type SafeEntity< * `World` class using the `archetype` method. */ export class Archetype< - Entity extends JsonObject, + Entity extends EntityBase, Components extends Array, > { #entities: EntityCollection>; diff --git a/packages/objecs/src/world.ts b/packages/objecs/src/world.ts index b99160e..641d2f9 100644 --- a/packages/objecs/src/world.ts +++ b/packages/objecs/src/world.ts @@ -1,8 +1,25 @@ -import { JsonObject } from "type-fest"; import { Archetype } from "./archetype.js"; +/** + * Allowed component value types. Prevents functions, symbols, and other + * non-serializable values from being used as component data. + */ +export type ComponentValue = + | string + | number + | boolean + | null + | ComponentValue[] + | { [key: string]: ComponentValue }; + +/** + * Base constraint for entity types. All entity properties must be + * JSON-compatible component values or undefined (for optional components). + */ +export type EntityBase = Record; + export type SafeEntity< - Entity extends JsonObject, + Entity extends EntityBase, Components extends keyof Entity, > = Entity & Required>; @@ -147,7 +164,7 @@ export class EntityCollection implements ReadonlyEntityCollection { /** * Container for Entities */ -export class World { +export class World { #archetypes = new Set>>(); #entities = new EntityCollection(); #componentIndex = new Map>>>(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea31434..def8813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,10 +230,6 @@ importers: version: 5.9.3 packages/objecs: - dependencies: - type-fest: - specifier: 5.4.0 - version: 5.4.0 devDependencies: '@antfu/ni': specifier: 28.1.0 From fa2229da684b64bee47f22da190ecc88964732ee Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Sun, 22 Feb 2026 19:29:15 -0500 Subject: [PATCH 2/3] docs: update CLAUDE.md to reflect EntityBase type change Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index efc85c5..8b9dd29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ node packages/game-benchmark/src/index.ts --no-render -g boids -d 10 -t 10 -l ob **Archetype** (`packages/objecs/src/archetype.ts`): A query that groups entities sharing the same component combination. Created via `world.archetype('component1', 'component2', ...)`. Supports `without()` for exclusion filters. -**Entity**: Plain JavaScript objects (must be `JsonObject` from type-fest). Components are simply properties on the entity object. +**Entity**: Plain JavaScript objects (must extend `EntityBase` — restricts components to JSON-compatible values, no functions/symbols). Components are simply properties on the entity object. ### Usage Pattern From 0ac352c82d2edce6ee94e79bd44f7b84f5a65488 Mon Sep 17 00:00:00 2001 From: Jake Klassen Date: Sun, 22 Feb 2026 20:16:50 -0500 Subject: [PATCH 3/3] test: add type-level tests for EntityBase component constraints 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 --- packages/objecs/src/world.test.ts | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/objecs/src/world.test.ts b/packages/objecs/src/world.test.ts index eea0805..372ecd2 100644 --- a/packages/objecs/src/world.test.ts +++ b/packages/objecs/src/world.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import { ReadonlyEntityCollection, World } from "./world.js"; +import { type EntityBase, ReadonlyEntityCollection, World } from "./world.js"; type Entity = { color?: string; @@ -320,4 +320,37 @@ describe("World", () => { renderSystem(); }); }); + + describe("EntityBase type constraint", () => { + it("should reject entities with function components at the type level", () => { + type InvalidEntity = { + callback?: () => void; + }; + + // @ts-expect-error — functions are not assignable to ComponentValue + new World(); + }); + + it("should reject entities with symbol components at the type level", () => { + type InvalidEntity = { + id?: symbol; + }; + + // @ts-expect-error — symbols are not assignable to ComponentValue + new World(); + }); + + it("should accept entities with valid JSON-compatible components", () => { + type ValidEntity = { + name?: string; + health?: number; + active?: boolean; + position?: { x: number; y: number }; + tags?: string[]; + metadata?: null; + }; + + expectTypeOf().toExtend(); + }); + }); });