Skip to content

doeixd/forma

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

形 Forma

A powerful, type-safe utility for creating immutable, struct-like data objects in TypeScript.

npm version License: MIT

Forma helps you craft robust, predictable, and self-contained data structures. It blends the safety of immutability with the power of composition, providing a rich API for validation, comparison, and creating smart, value-based objects that behave exactly as you'd expect.

  • 🛡️ Immutable by Default: Create objects that are safe to pass around your application without fear of mutation.
  • 🤝 Value-Based Equality: forma1.equals(forma2) works on content, not memory references.
  • 🧩 Composition with Traits: Add behavior like validation, ordering, and hashing with a clean, composable API.
  • Rich & Ergonomic API: A fluent, intuitive interface for everything from creation to transformation.
  • Standard Schema Compliant: Generate and use schemas that interoperate with other tools.

🏛️ The Philosophy: Why Forma?

In modern applications, data is king. Yet, JavaScript's default objects can be unpredictable. They are mutable, their equality checks are reference-based ({} !== {}), and extending their behavior often leads to messy inheritance patterns.

Forma was built on a few core beliefs:

  1. Data Should Be Predictable: When you compare two user objects with the same ID, they should be considered equal. When you pass a configuration object to a function, it shouldn't be modified unexpectedly. Forma enforces this predictability with immutability and value-based equality.

  2. Structure and Behavior Should Be Fused: A User object isn't just a bag of properties; it should know how to validate itself, how to compare itself to another User, or how to generate a hash. Forma allows you to create these "smart" data objects by fusing their structure (the data) with their behavior (the methods).

  3. Composition Over Inheritance: Instead of complex class hierarchies, Forma uses Traits. A trait is a reusable piece of functionality you can "mix in" to any data structure. Need ordering? Add the withOrder trait. Need hashing? Add withEquatable. This keeps your data definitions clean, declarative, and highly flexible.

Forma gives you the tools to define the "form" of your data—its shape, its rules, and its capabilities—all in one clear, declarative place.


🚀 Installation

npm install @doeixd/forma
# or
yarn add @doeixd/forma
# or
pnpm add @doeixd/forma

🧠 Core Concepts

Everything in Forma revolves around creating a FormaFactory using the forma function. This factory is your central point for creating, validating, and interacting with your data type.

1. Defining a forma

You define a data structure by giving it a name and a set of default properties.

import { forma } from '@doeixd/forma';

// Define a "form" for a Person.
// This creates a `Person` factory function.
const Person = forma('Person', {
  name: 'Anonymous',
  age: 0,
});

// The factory creates instances.
const user1 = Person({ name: 'Alice', age: 30 });

console.log(user1.name); // "Alice"
console.log(user1.age); // 30

2. Immutability and produce

Forma instances are immutable by default. To create a modified version, you use the .produce() method, which provides a mutable "draft" that you can change freely. Once your function finishes, a new, immutable instance is created with your changes, leaving the original untouched.

const olderUser = user1.produce(draft => {
  draft.age += 10;
});

console.log(user1.age);      // 30 (Original is unchanged)
console.log(olderUser.age); // 40 (New instance with updated age)

3. Value-Based Equality with .equals()

Forget ===. Forma instances are compared by their content.

import { forma, withEquatable } from '@doeixd/forma';

// Add the `withEquatable` trait to enable .equals() and .getHash()
const Point = forma('Point', { x: 0, y: 0 }, [withEquatable()]);

const p1 = Point({ x: 10, y: 20 });
const p2 = Point({ x: 10, y: 20 });
const p3 = Point({ x: 5, y: 15 });

console.log(p1 === p2);         // false (They are different objects in memory)
console.log(p1.equals(p2));     // true (Their values are identical)
console.log(p1.equals(p3));     // false

🛠️ Advanced Usage with Traits

Traits are the heart of Forma's power and flexibility. They are functions that you pass into the forma definition to add new capabilities.

Ordering and Sorting with withOrder

The withOrder trait gives your data structure a .compare() method and adds a powerful Order toolkit to its factory for sorting and comparison.

import { forma, withOrder } from '@doeixd/forma';

// Define ordering functions
const byAge = (a, b) => (a.age > b.age ? 1 : a.age < b.age ? -1 : 0);
const byName = (a, b) => a.name.localeCompare(b.name);

// Order Persons by age, then by name.
const Person = forma('Person', { name: '', age: 0 }, [
  // The withOrder trait adds the `Order` toolkit to the factory.
  withOrder((a, b) => {
    // Combine logic directly or use the `combine` helper
    const ageOrder = byAge(a, b);
    if (ageOrder !== 0) return ageOrder;
    return byName(a, b);
  })
]);

const alice = Person({ name: 'Alice', age: 30 });
const bob = Person({ name: 'Bob', age: 25 });
const anotherAlice = Person({ name: 'Alice', age: 28 });

const people = [alice, bob, anotherAlice];

// Use the static sorter from the factory's Order toolkit
const sorted = Person.Order.sort(people);
// Result: [bob, anotherAlice, alice] (sorted by age, then name)

console.log(alice.compare(bob)); // 1 (Alice is "greater" than Bob by age)

Hashing and Equivalence with withEquatable

The withEquatable trait also provides .getHash() for performance optimizations (e.g., in Sets) and .isEquivalent() for defining custom equality rules.

import { forma, withEquatable } from '@doeixd/forma';

const User = forma('User',
  { id: '', name: '', version: 0 },
  [
    withEquatable({
      // Define a custom equivalence: are two users the same "entity"?
      entity: (a, b) => a.id === b.id,
    })
  ]
);

const userV1 = User({ id: 'u1', name: 'Alex', version: 1 });
const userV2 = User({ id: 'u1', name: 'Alex', version: 2 });

// Deep equality checks everything, so this is false.
console.log(userV1.equals(userV2)); // false

// But for the purpose of being the same entity, they are equivalent.
console.log(userV1.isEquivalent(userV2, 'entity')); // true

Schema Validation

Forma can turn your data definitions into powerful, spec-compliant validators. The withStandardSchema trait makes your factory compatible with the Standard Schema V1 specification.

import { forma, withStandardSchema } from '@doeixd/forma';

// Define the form and its validation rules at the same time
const Product = forma('Product',
  { id: '', price: 0, tags: [] as string[] },
  [
    withStandardSchema({
      id: v => (typeof v === 'string' && v.startsWith('prod_')) || 'ID must start with prod_',
      price: v => (typeof v === 'number' && v > 0) || 'Price must be a positive number',
      tags: v => Array.isArray(v) || 'Tags must be an array',
    })
  ],
  { is_extendable: false } // Forbid extra properties
);

async function validate(data) {
  const result = await Product['~standard'].validate(data);

  if (result.issues) {
    console.error('Validation Failed:', result.issues);
  } else {
    console.log('Validation Succeeded! Created instance:', result.value);
  }
}

validate({ id: 'prod_123', price: 99.99, tags: ['electronics'] });
// ✅ Validation Succeeded!

validate({ id: '123', price: -10, tags: 'oops' });
// ❌ Validation Failed: [ { message: 'ID must start with prod_' ... }, ... ]

⚡️ Working with Other Data Structures

Primitives (string, number, etc.)

Forma is designed for creating structured, object-like data. It is not intended for wrapping primitive values. The defaults parameter must be an object.

// ⛔️ This is not a valid use case
// const MyNumber = forma('MyNumber', 123);

Array-like Formas

You can absolutely create forma instances that behave like arrays! This is useful for creating typed lists with custom methods. This requires a withPrototype trait (which you can easily create).

// A helper trait to set the prototype
const withPrototype = (proto) => () => Object.setPrototypeOf({}, proto);

const UserList = forma('UserList', [], [withPrototype(Array.prototype)]);

const users = UserList();
users.push(
  Person({ name: 'Carol', age: 40 }),
  Person({ name: 'David', age: 35 })
);

console.log(users.length); // 2
console.log(users[0].name); // "Carol"
console.log(Array.isArray(users)); // true

Usage in Sets and Maps

A key reason for the withEquatable trait is to enable reliable use of forma instances in value-based collections.

The Problem: By default, JavaScript's Set and Map use reference equality. Adding two identical-looking objects results in two separate entries.

The Solution: The .equals() and .getHash() methods provided by withEquatable are the essential building blocks for creating or using custom Set or Map implementations that understand value equality. While Forma doesn't provide these collections out of the box, it makes your objects ready for them.

const p1 = Point({ x: 1, y: 1 });
const p2 = Point({ x: 1, y: 1 });

const mySet = new Set();
mySet.add(p1);
mySet.add(p2);

console.log(mySet.size); // 2 (because p1 !== p2 by reference)

// In a hypothetical ValueSet that uses .equals() and .getHash()...
// const myValueSet = new ValueSet();
// myValueSet.add(p1);
// myValueSet.add(p2);
// console.log(myValueSet.size); // 1 (because p1.equals(p2) is true)

⚠️ Troubleshooting & Gotchas

  • TypeScript can't see trait properties. Traits add methods at runtime. To make TypeScript aware of them, you need to use a type assertion.

    interface Equatable {
      equals(other: any): boolean;
    }
    const Point = forma('Point', { x: 0, y: 0 }, [withEquatable()]);
    // Assert the final type
    const p = Point() as ReturnType<typeof Point> & Equatable;
    p.equals(p); // ✅ Now TypeScript knows .equals() exists
  • Immutability is key. Never try to modify a forma instance directly (e.g., myInstance.prop = 'new'). This will either fail (if frozen) or have no effect on other parts of your code. Always use .produce() to create a new, updated version.

  • equals() vs. isEquivalent(). Remember the difference: .equals() is a strict, deep-value comparison. .isEquivalent() is for your own custom, purpose-driven equality logic (e.g., "are these two users the same person, regardless of version?").

  • Performance Considerations. Forma is highly optimized, but creating new objects on every .produce() call and performing deep equality checks has a cost. For most web and application development, this is perfectly fine. For performance-critical code like game loops or intensive data processing, be mindful of how frequently you are creating new instances.


📖 API Reference

forma(name, defaults, traits, options)

  • name: string: The name of the data structure.
  • defaults: object: An object of default values.
  • traits?: Trait[]: An array of traits to apply.
  • options?: FormaOptions:
    • is_sealed?: boolean (default: true): Whether to call Object.seal on instances.
    • is_frozen?: boolean (default: false): Whether to call Object.freeze on instances.
    • is_extendable?: boolean (default: true): Whether new properties can be added at creation time. If false, extra properties are stripped.
    • strict_mode?: boolean (default: false): Whether to throw errors on trait application failure.

Instance Methods

  • .produce(fn): Creates a new, modified instance.
  • .is(other): Checks if another object is an instance of this forma type.
  • .keys(): Returns the instance's keys.
  • .entries(): Returns the instance's [key, value] entries.
  • .equals(other): (from withEquatable) Performs a deep, value-based equality check.
  • .getHash(): (from withEquatable) Returns a 32-bit integer hash of the instance's content.
  • .isEquivalent(other, by): (from withEquatable) Checks for equality using a named equivalence relation.
  • .compare(other): (from withOrder) Compares this instance to another, returning -1, 0, or 1.

Factory (Static) Methods

  • Factory.is(other): Same as the instance method.
  • Factory.keys(): Returns the keys of the defaults object.
  • Factory.Order.sort(arr): (from withOrder) Sorts an array of instances without mutating it.
  • Factory['~standard'].validate(data): (from withStandardSchema) Validates raw data against the schema.

## 📄 License

MIT

About

A type-safe utility for creating immutable, struct-like data objects in TypeScript.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published