A powerful, type-safe utility for creating immutable, struct-like data objects in TypeScript.
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.
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:
-
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.
-
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 anotherUser
, 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). -
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? AddwithEquatable
. 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.
npm install @doeixd/forma
# or
yarn add @doeixd/forma
# or
pnpm add @doeixd/forma
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.
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
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)
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
Traits are the heart of Forma's power and flexibility. They are functions that you pass into the forma
definition to add new capabilities.
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)
The withEquatable
trait also provides .getHash()
for performance optimizations (e.g., in Set
s) 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
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_' ... }, ... ]
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);
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
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)
-
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.
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 callObject.seal
on instances.is_frozen?: boolean
(default:false
): Whether to callObject.freeze
on instances.is_extendable?: boolean
(default:true
): Whether new properties can be added at creation time. Iffalse
, extra properties are stripped.strict_mode?: boolean
(default:false
): Whether to throw errors on trait application failure.
.produce(fn)
: Creates a new, modified instance..is(other)
: Checks if another object is an instance of thisforma
type..keys()
: Returns the instance's keys..entries()
: Returns the instance's[key, value]
entries..equals(other)
: (fromwithEquatable
) Performs a deep, value-based equality check..getHash()
: (fromwithEquatable
) Returns a 32-bit integer hash of the instance's content..isEquivalent(other, by)
: (fromwithEquatable
) Checks for equality using a named equivalence relation..compare(other)
: (fromwithOrder
) Compares this instance to another, returning-1
,0
, or1
.
Factory.is(other)
: Same as the instance method.Factory.keys()
: Returns the keys of thedefaults
object.Factory.Order.sort(arr)
: (fromwithOrder
) Sorts an array of instances without mutating it.Factory['~standard'].validate(data)
: (fromwithStandardSchema
) Validates raw data against the schema.
## 📄 License
MIT