Skip to content

wentout/typeomatica

Repository files navigation

TypeØmatica

Coverage Status

NPM GitHub package.json version GitHub last commit

$ npm install typeomatica

Strict Runtime Type Checker for JavaScript Objects

TypeØmatica uses JavaScript Proxies to enforce type safety at runtime, exactly as TypeScript expects at compile time. Once a property is assigned a value, its type is locked and cannot be changed.


Quick Start

import { BaseClass } from 'typeomatica';

class User extends BaseClass {
  declare name: string;
  declare age: number;
  constructor() {
    super();
    this.name = 'default';
    this.age = 0;
  }
}

const user = new User();

// ✓ Valid assignments
user.name = 'John';
user.age = 25;

// ✗ Runtime error (even if TypeScript is bypassed with @ts-ignore)
user.age = '25';  // TypeError: Type Mismatch

Important: initialized class fields (name = 'default') are stored as own properties and bypass the proxy. Use declare name: string; plus constructor assignment, or see FieldConstructor for custom descriptors.


Module Support

TypeØmatica ships both CommonJS and ESM builds:

  • CommonJS: require('typeomatica') resolves to lib/index.js.
  • ESM: import { BaseClass, FieldConstructor } from 'typeomatica' resolves to lib/esm/esm.js.
  • The dual build is produced by tsconfig.json (CJS) and tsconfig.esm.json (ESM).
  • package.json exports maps ".".require to lib/index.js and ".".import to lib/esm/esm.js.

Development Commands

npm run build        # clean build of lib/ and lib/esm/
npm run test:cov     # Jest CJS tests + 100% coverage
npm run test:esm     # Vitest true-ESM import tests
npm run examples     # run all examples/
npm run lint:src     # ESLint on src/

Architecture

  • src/index.ts — shared core: BaseClass, BaseConstructorPrototype (default export), @Strict decorator, proxy handlers, and CJS module.exports setup.
  • src/esm.ts — ESM entry point that re-exports the default and named bindings from src/index.ts.
  • src/fields.tsFieldConstructor class for custom property descriptors.
  • src/types/*.ts — type-category handlers (primitives, objects, functions, special, nullish).
  • test/index.ts — Jest CJS test suite (50 tests, 100% coverage).
  • test/esm/ — Vitest tests that exercise the actual ESM exports map.
  • examples/ — runnable integration examples.

Coverage Policy

  • Jest CJS tests must keep 100% coverage across statements, branches, functions, and lines.
  • Code paths that can only be reached through true ESM imports are covered by Vitest in test/esm/.
  • Istanbul ignore hints are preserved because tsconfig.json sets removeComments: false.

Table of Contents


What is TypeØmatica?

TypeØmatica provides strict runtime type checking for JavaScript objects using Proxy-based interception. It ensures that once a property is initialized with a type, that type cannot be violated at runtime.

Key Features:

  • Runtime type enforcement (complements TypeScript's compile-time checks)
  • Proxy-based property interception
  • Type locking after initial assignment
  • Prevents prototype mutation, property redefinition, and deletion

Installation

npm install typeomatica

Core Concepts

How It Works

TypeØmatica wraps objects with JavaScript Proxies that intercept:

  • get - Property reads (undefined properties return undefined)
  • set - Property writes (with type validation)
  • setPrototypeOf - Blocks prototype changes
  • defineProperty - Blocks property redefinition
  • deleteProperty - Blocks property deletion

Type Categories

Category Types Behavior
primitives string, number, boolean Type-locked accessors
special bigint, symbol, undefined Same typeof required
nullish null Any assignment throws after initialization
objects object, arrays, dates Same constructor type required
functions methods Restricted on data types

Development

  • For AI agents / contributors: see AGENTS.md for the file map, build pipeline, coverage rules, and conventions.
  • For human readers: see FOR_HUMANS.md for the motivation, mental model, and friendly examples.
  • Runnable patterns: see EXAMPLES.md for a catalog of all examples.

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.

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. BaseClassextends

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

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 }).
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. super() accepts an optional initial-state object and TypeomaticaOptions.

import { BaseClass } from 'typeomatica';

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

BaseConstructorPrototype (default export)

Functional equivalent of BaseClass. The default export of the package is BaseConstructorPrototype.

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

import BasePrototype from 'typeomatica';
// or: import { BaseConstructorPrototype } from 'typeomatica';

const Base = BasePrototype({ initialProp: 123 });
class MyClass extends Base { }

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

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.

import { Strict } from 'typeomatica';

@Strict()
class MyClass {
  declare field: number;
  constructor() {
    this.field = 0;
  }
}

Decorator Arguments:

  • First argument: optional target object used as the prototype base
  • Second argument: optional options object

FieldConstructor

Build custom property descriptors with controlled getters and setters. When a FieldConstructor instance is assigned to a BaseClass/BasePrototype property, its get and set methods are used directly as the property descriptor.

import { BaseClass, FieldConstructor, SymbolInitialValue } from 'typeomatica';

// Default FieldConstructor: read-only field
const createdAt = new FieldConstructor(new Date());

class Record extends BaseClass {
  createdAt = createdAt as unknown | Date;
}

const record = new Record();
console.log(record.createdAt);           // ✓ Works
// @ts-ignore
record.createdAt = new Date();           // ✗ TypeError: Re-Assirnment is Forbidden

Custom read/write field:

class MutableField extends FieldConstructor {
  _value: string;
  constructor(value: string) {
    super(value);
    this._value = value;
    Reflect.defineProperty(this, 'enumerable', { value: true });
    const self = this;
    Reflect.defineProperty(this, 'get', {
      get() {
        return function () { return self._value; };
      },
      enumerable: true
    });
    Reflect.defineProperty(this, 'set', {
      get() {
        return function (value: string) { self._value = value; };
      },
      enumerable: true
    });
  }
}

const displayName = new MutableField('Guest');

class User extends BaseClass {
  displayName = displayName as unknown | string;
}

const user = new User();
console.log(user.displayName);  // 'Guest'
user.displayName = 'Alice';     // ✓ Works
console.log(user.displayName);  // 'Alice'

Function-constructor getter:

If you prefer function constructors over classes, subclass FieldConstructor through the prototype chain:

function TimestampField(this: any, value: Date) {
  return Reflect.construct(FieldConstructor, [value], TimestampField);
}
TimestampField.prototype = Object.create(FieldConstructor.prototype);
Reflect.defineProperty(TimestampField.prototype, 'constructor', {
  value: TimestampField,
  writable: true,
  configurable: true
});

Reflect.defineProperty(TimestampField.prototype, 'get', {
  get() {
    const self = this as FieldConstructor;
    return function () {
      return self[SymbolInitialValue];
    };
  },
  enumerable: true
});

const created = new (TimestampField as any)(new Date());

class Event extends BaseClass {
  created = created as unknown | Date;
}

const event = new Event();
console.log(event.created);  // Date instance

Buffer field with toJSON support:

Wrap a Buffer so it serializes cleanly with JSON.stringify:

class BufferField extends FieldConstructor {
  buffer: Buffer;
  constructor(value: Buffer) {
    super(value);
    this.buffer = value;
    Reflect.defineProperty(this, 'enumerable', { value: true });
    const self = this;
    Reflect.defineProperty(this, 'get', {
      get() {
        return function () {
          return Object.create(self.buffer, {
            toJSON: {
              value: () => self.buffer.toJSON()
            },
            valueOf: {
              value: () => self.buffer
            }
          });
        };
      },
      enumerable: true
    });
  }
}

const payload = new BufferField(Buffer.from('hello'));

class Message extends BaseClass {
  payload = payload as unknown | Buffer;
}

const message = new Message();
console.log(JSON.stringify(message.payload)); // {"type":"Buffer","data":[104,101,108,108,111]}

Typed unsigned integer fields:

Create UInt8, UInt16, and UInt32 fields that clamp values, validate types, and expose Symbol.toPrimitive for arithmetic:

function createUIntField(bits: 8 | 16 | 32) {
  const max = 2 ** bits - 1;

  return class UIntField extends FieldConstructor {
    numericValue: number;
    constructor(value: number) {
      super(value);
      const initial = parseInt(String(value), 10);
      if (isNaN(initial)) {
        throw new TypeError('Type Mismatch');
      }
      this.numericValue = Math.max(0, Math.min(max, initial));
      Reflect.defineProperty(this, 'enumerable', { value: true });

      const self = this;
      Reflect.defineProperty(this, 'get', {
        get() {
          return function () {
            return {
              valueOf: () => self.numericValue,
              [Symbol.toPrimitive]: (hint: string) => {
                return hint === 'number' ? self.numericValue : String(self.numericValue);
              }
            };
          };
        },
        enumerable: true
      });

      Reflect.defineProperty(this, 'set', {
        get() {
          return function (newValue: number) {
            const num = parseInt(String(newValue), 10);
            if (isNaN(num)) {
              throw new TypeError('Type Mismatch');
            }
            self.numericValue = Math.max(0, Math.min(max, num));
          };
        },
        enumerable: true
      });
    }
  };
}

const UInt8Field = createUIntField(8);
const UInt16Field = createUIntField(16);
const UInt32Field = createUIntField(32);

class Registers extends BaseClass {
  flags = new UInt8Field(0) as unknown | number;
  count = new UInt16Field(1000) as unknown | number;
  timestamp = new UInt32Field(Date.now()) as unknown | number;
}

const registers = new Registers();
registers.flags = 255;          // ✓ Works
// @ts-ignore
registers.flags = 256;          // ✗ clamps to 255 (or throw if you prefer)
// @ts-ignore
registers.flags = 'not a number'; // ✗ TypeError: Type Mismatch

const doubled = registers.count.valueOf() * 2;

Notes:

  • Set enumerable: true on the instance if the property should appear in Object.keys().
  • Reusing the same FieldConstructor instance across multiple class instances will share state.
  • Access the original constructor value through FieldConstructor.SymbolInitialValue or instance[SymbolInitialValue].

Options Interface

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

// With BaseClass - strict access checking enabled
class SecureData extends BaseClass {
  declare secret: string;
  constructor() {
    super(undefined, { strictAccessCheck: true });
    this.secret = '';
  }
}

// With BasePrototype - strict access checking enabled
const SecureBase = BasePrototype({ data: '' }, { strictAccessCheck: true });
class User extends SecureBase {
  declare name: string;
  constructor() {
    super();
    this.name = '';
  }
}

// With @Strict decorator - strict access checking enabled
@Strict({ starterProp: true }, { strictAccessCheck: true })
class Product {
  declare price: number;
  constructor() {
    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

import BasePrototype, { 
  BaseConstructorPrototype,         // Functional equivalent of BaseClass
  BaseClass,                        // Primary strict-type base class
  Strict,                           // Decorator for strict typing
  SymbolTypeomaticaProxyReference,  // Access proxy internals
  SymbolInitialValue,               // Access original values
  baseTarget                        // Utility to create a null-prototype object
} from 'typeomatica';

// FieldConstructor is also available as a named export
import { FieldConstructor } from 'typeomatica';

Usage Patterns

Pattern 1: Extending BaseClass

import { BaseClass } from 'typeomatica';

class User extends BaseClass {
  declare name: string;
  declare age: number;
  declare active: boolean;
  constructor() {
    super();
    this.name = '';
    this.age = 0;
    this.active = true;
  }
}

const user = new User();
user.name = 'John';     // ✓ Works
user.age = 25;          // ✓ Works

// @ts-ignore
user.age = '25';        // ✗ TypeError: Type Mismatch

Pattern 2: @Strict Decorator

import { Strict } from 'typeomatica';

@Strict()
class Product {
  declare id: number;
  declare title: string;
  declare price: number;
  constructor() {
    this.id = 0;
    this.title = '';
    this.price = 0;
  }
}

const product = new Product();
product.price = 29.99;      // ✓ Works
// @ts-ignore
product.price = '$29.99';   // ✗ TypeError: Type Mismatch

Pattern 3: BaseClass as Prototype

import { BaseClass } from 'typeomatica';

class UserData {
  declare name: string;
  declare age: number;
  constructor() {
    this.name = 'default';
    this.age = 0;
  }
}

// Inject type checking into prototype chain
Object.setPrototypeOf(UserData.prototype, new BaseClass());

const user = new UserData();
user.name = 'John';     // ✓ Works
// @ts-ignore
user.name = 123;        // ✗ TypeError: Type Mismatch

Type Examples

Primitives

import { BaseClass } from 'typeomatica';

class Primitives extends BaseClass {
  declare str: string;
  declare num: number;
  declare bool: boolean;
  declare bigint: bigint;
  constructor() {
    super();
    this.str = 'hello';
    this.num = 42;
    this.bool = true;
    this.bigint = BigInt(100);
  }
}

const p = new Primitives();
p.str = 'world';          // ✓ Works
p.num = 100;              // ✓ Works
// @ts-ignore
p.str = 123;              // ✗ TypeError: Type Mismatch

Null and Undefined

class Nullable extends BaseClass {
  declare nullValue: null;
  declare undefinedValue: undefined;
  constructor() {
    super();
    this.nullValue = null;
    this.undefinedValue = undefined;
  }
}

const n = new Nullable();
n.nullValue = null;              // ✓ First assignment creates the property
// @ts-ignore
n.nullValue = null;              // ✗ TypeError: Type Mismatch (null cannot be reassigned)
// @ts-ignore
n.nullValue = undefined;         // ✗ TypeError: Type Mismatch

n.undefinedValue = undefined;    // ✓ Works
// @ts-ignore
n.undefinedValue = 123;          // ✗ TypeError: Type Mismatch

Objects

class WithObject extends BaseClass {
  declare data: object;
  declare list: number[];
  constructor() {
    super();
    this.data = {};
    this.list = [];
  }
}

const w = new WithObject();
w.data = { a: 1 };              // ✓ Works
w.list = [1, 2, 3];             // ✓ Works
// @ts-ignore
w.data = 123;                   // ✗ TypeError: Type Mismatch
// @ts-ignore
w.data = new Set();             // ✗ TypeError: Type Mismatch (Set !== Object)

Working with Wrapped Values

TypeØmatica wraps primitives to enforce type safety. Use valueOf() for operations.

Numeric Operations

class Calc extends BaseClass {
  declare count: number;
  constructor() {
    super();
    this.count = 10;
  }
}

const calc = new Calc();

// ✗ Direct arithmetic throws
const result = calc.count + 5;  // ReferenceError: Value Access Denied

// ✓ Use valueOf()
const result = calc.count.valueOf() + 5;  // 15
const sum = 3 + +calc.count;               // 13

String Operations

class Text extends BaseClass {
  declare message: string;
  constructor() {
    super();
    this.message = 'hello';
  }
}

const text = new Text();
text.message.valueOf().toUpperCase();  // 'HELLO'
text.message.valueOf().length;         // 5

Error Reference

Error Message Error Type When Thrown
Type Mismatch TypeError Wrong type assigned to property
Value Access Denied ReferenceError Property accessed from wrong context (only when strictAccessCheck: true)
Functions are Restricted TypeError Function assigned to data property
Re-Assirnment is Forbidden TypeError Modifying read-only custom field
Setting prototype is not allowed! Error Calling Object.setPrototypeOf()
Defining new Properties is not allowed! Error Calling Object.defineProperty()
Properties Deletion is not allowed! Error Calling delete on property

Integration with Mnemonica

TypeØmatica integrates seamlessly with mnemonica for combined prototype inheritance and runtime type safety.

import { decorate } from 'mnemonica';
import { BaseClass, Strict } from 'typeomatica';

@decorate()
@Strict()
class Entity extends BaseClass {
  declare id: string;
  declare createdAt: Date;
  constructor() {
    super();
    this.id = '';
    this.createdAt = new Date();
  }
}

// Works with mnemonica's inheritance system
const User = Entity.define('User', function(this: { email: string }) {
  this.email = '';
});

const user = new User();
// @ts-ignore
user.email = 123;  // ✗ TypeError: Type Mismatch

Decorator Order Matters:

  • @Strict() must come AFTER @decorate() (inner decorator)
  • Decorators apply bottom-to-top

For complete integration documentation, see mnemonica's TypeØmatica guide.


License

MIT

Copyright (c) 2019-2024 https://github.com/wentout

About

Simple runtime type checker for JavaScript~Typescript

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors