Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ npm run lint:src # ESLint on src/
npm run lint:lib # ESLint on lib/
```

Documentation-only changes (`.md` files) do not require running tests or linters.

## Module architecture

- CJS consumers get `lib/index.js` via `main` / `exports.require`.
Expand All @@ -54,6 +56,66 @@ npm run lint:lib # ESLint on lib/
- Class properties that should be type-checked must be declared with `declare prop: Type;` and assigned inside the constructor. Initialized class fields (`prop: Type = value`) bypass the Proxy.
- `src/esm.ts` must not introduce new runtime logic; it only re-exports.

## Prototype wiring internals

Understanding how the proxy is attached prevents accidental prototype pollution or coverage regressions.

### `BaseClass`

The constructor checks whether the instance already reaches a TypeØmatica proxy through its prototype chain. If it does, it returns immediately.

Otherwise it walks up the prototype chain until it finds the class that directly extends `BaseClass` and installs the proxy there. This keeps intermediate classes intact. For `class C extends B extends A extends BaseClass` the resulting chain is:

```
instance → C.prototype → B.prototype → A.prototype → proxy → target
```

Every class prototype in that chain (`C.prototype`, `B.prototype`, and `A.prototype`) is frozen by default, so none of them can be reassigned or grow new properties at runtime. Pass `{ frozenPrototypes: false }` in `TypeomaticaOptions` to keep them mutable.

For a direct subclass `class X extends BaseClass` the walk stops immediately:

```
instance → X.prototype → proxy → target
```

For a direct `new BaseClass(...)` call the instance itself is wired and `BaseClass.prototype` is frozen, but not mutated into a proxy.

### `BaseConstructorPrototype`

In constructor mode the function walks the instance's prototype chain until it reaches `BaseConstructorPrototype.prototype` (or a prototype whose `constructor` is `BaseConstructorPrototype`). The object just before that stopping point is treated as the class prototype:

```
instance → DerivedClass.prototype → BaseConstructorPrototype.prototype
```

It sets `DerivedClass.prototype.__proto__` to the proxy and freezes `DerivedClass.prototype` by default (configurable via `frozenPrototypes: false`):

```
instance → DerivedClass.prototype → proxy → target
```

For a direct `new BaseConstructorPrototype(...)` call there is no derived class, so the instance itself becomes the wired object and `BaseConstructorPrototype.prototype` is frozen as the class prototype.

### `@Strict()` decorator

At decoration time it reparents the class prototype to a plain object that itself inherits from the proxy, then freezes the class prototype by default (configurable via `frozenPrototypes: false`):

```
Class.prototype → plainReplacer → proxy → target
```

The extra plain replacer exists because the class already owns its methods and accessors on `Class.prototype`; the proxy only needs to handle missing properties.

### Direct extension

`class MyClass extends BaseConstructorPrototype {}` is valid, but after the first instance the chain becomes:

```
MyClass.prototype → proxy → target → null
```

Therefore `instanceof BaseConstructorPrototype` will be `false` after wiring. The factory form `class MyClass extends BasePrototype({...})` is the preferred API.

## What to discuss before changing

- Proxy handler logic in `createHandlers()`.
Expand Down
12 changes: 12 additions & 0 deletions FOR_HUMANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ class User extends BaseClass {

Or use `FieldConstructor` if you need custom getters/setters.

## Three ways to attach it

TypeØmatica gives you three APIs that all put a Proxy on your class prototype chain and then freeze that prototype. Pick whichever fits your existing code shape.

| API | Looks like | When it wires | Initial state | Best for |
|---|---|---|---|---|
| `BaseClass` | `class X extends BaseClass { ... }` | First instance constructed | Per instance via `super({...})` | Modern class-based code |
| `BasePrototype` | `class X extends BasePrototype({...}) { ... }` or `const X = BasePrototype({...})` | First instance constructed | Bound at class/factory creation | Function constructors or factory-returned constructors |
| `@Strict()` | `@Strict({...}) class X { ... }` | When the class is decorated | Bound at decoration | Existing classes you do not want to `extend` |

The behavior is the same: after a property is assigned, its type is locked, and attempts to mutate the prototype or add new properties to it are rejected.

## Five-minute example

```typescript
Expand Down
119 changes: 115 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,27 +153,111 @@ TypeØmatica wraps objects with JavaScript Proxies that intercept:

---

## The Three Entry Points

TypeØmatica exposes three APIs that all do the same underlying thing — attach a Proxy to a prototype chain so property writes are type-checked and the class prototype is frozen. They differ in how you create and attach that prototype.

| | `BaseClass` | `BasePrototype` / `BaseConstructorPrototype` | `@Strict()` |
|---|---|---|---|
| **How you use it** | `class X extends BaseClass { ... }` | `class X extends BasePrototype({...}) { ... }` or `const X = BasePrototype({...})` | `@Strict({...}) class X { ... }` |
| **Wiring moment** | First time an instance is constructed | First time an instance is constructed | When the class is decorated (module load) |
| **Initial state** | Can pass a *new* target per instance via `super({...})` | Target is bound at class/factory creation; `super(...)` args are ignored because the constructor is bound | Target/options are bound at decoration |
| **Prototype chain shape** | `X.prototype → … → classDirectlyExtendingBase.prototype → proxy → target` | `X.prototype → proxy → target` (class usage) | `X.prototype → plain replacer → proxy → target` |
| **Function-constructor support** | No | Yes — `BasePrototype` can return a constructor function | No |
| **Default export?** | No, it is a named export/property | Yes, `require('typeomatica')` is `BaseConstructorPrototype` | No, exported as `Strict` |

### 1. `BaseConstructorPrototype` (default export) — manual prototype manipulation

`BaseConstructorPrototype` is the lowest-level API. It returns an instance that is designed to be assigned as the prototype of an existing constructor function using `Object.setPrototypeOf`.

```typescript
import BasePrototype from 'typeomatica';

const MyConstructor = function (this: { value: number }) {
this.value = 0;
};

const baseProto = new BasePrototype({ value: 0 });
Object.setPrototypeOf(MyConstructor.prototype, baseProto);

const instance = new MyConstructor();
instance.value = 42; // ✓ Works
// @ts-ignore
instance.value = '42'; // ✗ TypeError: Type Mismatch
```

Use this when you already have a constructor function and need to inject type checking into its prototype chain manually, or when you want a factory that returns a constructor function.

### 2. `BaseClass` — `extends`

`BaseClass` is the same mechanism wrapped in a class. When you `extends BaseClass`, TypeØmatica internally sets the proxy prototype for you.

```typescript
import { BaseClass } from 'typeomatica';

class MyClass extends BaseClass {
declare value: number;
constructor() {
super({ value: 0 });
this.value = this.value;
}
}
```

`super()` accepts an optional initial-state object and `TypeomaticaOptions`. Because the proxy target is set up during `super()`, you usually re-assign the inherited values inside the constructor (as shown above) so they pass through the proxy and become type-locked. Unlike `BasePrototype`, `BaseClass` lets you pass a *new* initial-state object per instance.

### 3. `@Strict()` — decorator

`@Strict()` does the same prototype injection but as a class decorator. It accepts the same optional arguments as `BaseConstructorPrototype`:

- First argument: initial-state object (`{ starterProp: value }`).
- Second argument: `TypeomaticaOptions` (e.g., `{ strictAccessCheck: true }`).

```typescript
import { Strict } from 'typeomatica';

@Strict({ value: 0 }, { strictAccessCheck: true })
class MyClass {
declare value: number;
constructor() {
this.value = 0;
}
}
```

Use `@Strict()` when you want a clean class decorator and need to pass initial state or options without changing the class `extends` clause.

### Which one should I use?

- Use **`BaseClass`** for modern `extends`-based classes, especially when you want per-instance initial state through `super({...})`.
- Use **`BasePrototype(...)`** when you need a factory that returns a constructor, or when working with function constructors.
- Use **`@Strict()`** when you want to Typeømatica-wrap an existing class without changing its `extends` clause.

---

## API Reference

### BaseClass

The primary class for strict-type objects.
The primary class for strict-type objects. `super()` accepts an optional initial-state object and `TypeomaticaOptions`.

```typescript
import { BaseClass } from 'typeomatica';

class MyClass extends BaseClass {
declare field: string;
constructor() {
super();
this.field = 'value';
super({ field: 'value' });
this.field = this.field;
}
}
```

### BaseConstructorPrototype (default export)

Functional equivalent of `BaseClass`. The default export of the package is `BaseConstructorPrototype`, which can be called without `new` to produce a base constructor.
Functional equivalent of `BaseClass`. The default export of the package is `BaseConstructorPrototype`.

**Form 1 — call without `new` to get a base constructor:**

```typescript
import BasePrototype from 'typeomatica';
Expand All @@ -183,6 +267,21 @@ const Base = BasePrototype({ initialProp: 123 });
class MyClass extends Base { }
```

**Form 2 — call with `new` to get a prototype object for `Object.setPrototypeOf`:**

```typescript
import BasePrototype from 'typeomatica';

const MyConstructor = function (this: { initialProp: number }) {
this.initialProp = 0;
};

const baseProto = new BasePrototype({ initialProp: 123 });
Object.setPrototypeOf(MyConstructor.prototype, baseProto);

const instance = new MyConstructor();
```

### @Strict() Decorator

Apply strict typing without extending BaseClass.
Expand Down Expand Up @@ -415,11 +514,13 @@ const doubled = registers.count.valueOf() * 2;
```typescript
interface TypeomaticaOptions {
strictAccessCheck?: boolean; // default: false
frozenPrototypes?: boolean; // default: true
}
```

**Options:**
- `strictAccessCheck: true` - Enables strict receiver checking (throws `ACCESS_DENIED` error when property is accessed from wrong context)
- `frozenPrototypes: false` - Keeps class prototypes mutable so methods/properties can be added at runtime (foot-gun allowed)

### Usage with Options

Expand Down Expand Up @@ -451,6 +552,16 @@ class Product {
this.price = 0;
}
}

// With BaseClass - allow runtime prototype mutation (not recommended)
class ExtensibleBase extends BaseClass {
declare value: number;
constructor() {
super(undefined, { frozenPrototypes: false });
this.value = 0;
}
}
ExtensibleBase.prototype.helper = () => 'ok';
```

### Symbol Exports
Expand Down
10 changes: 5 additions & 5 deletions lib/esm/fields.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ interface FieldDefinition {
[SymbolInitialValue]: unknown;
}
export declare class FieldConstructor implements FieldDefinition {
[SymbolInitialValue]: unknown;
get get(): () => unknown;
get set(): () => never;
constructor(value: unknown);
static get SymbolInitialValue(): symbol;
[SymbolInitialValue]: unknown;
get get(): () => unknown;
get set(): () => never;
constructor(value: unknown);
static get SymbolInitialValue(): symbol;
}
export {};
3 changes: 2 additions & 1 deletion lib/esm/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FieldConstructor } from './fields.js';
export interface TypeomaticaOptions {
strictAccessCheck?: boolean;
frozenPrototypes?: boolean;
}
export declare const baseTarget: (_proto?: object) => any;
export declare const SymbolTypeomaticaProxyReference: unique symbol;
Expand All @@ -9,7 +10,7 @@ export declare const BaseConstructorPrototype: {
<T extends object | {}, S extends T>(_target?: S extends infer InferredS ? InferredS : {}, options?: TypeomaticaOptions): S;
};
export declare class BaseClass {
constructor(_target?: object, options?: TypeomaticaOptions);
constructor(_target?: object, options?: TypeomaticaOptions);
}
export declare const SymbolInitialValue: symbol;
declare const FieldConstructorExport: typeof FieldConstructor;
Expand Down
Loading
Loading