@dgcode/field-manager
utility class and decorator for maintaining field definitions
Install
$ npm install @dgcode/field-manager
While compatible with JavaScript, field-manager is mainly designed to work with TypeScript. Make sure to enable { "experimentalDecorators": true }
in the compilerOptions
of your tsconfig.json
file.
Usage
import { Field } from '@dgcode/field-manager';
class Vehicle {
@Field() type: string;
@Field() wheels: number;
}
console.log(Field.names(Vehicle)); // ['type', 'wheels']
Field metadata
The main purpose of this library is to attach arbitrary data to any field we like. For example, if we wanted to attach specific content to our type
field (marking it with { hello: 'world' }
) and our wheels
field (marking it with { foo: 5 }
):
import { Field } from '@dgcode/field-manager';
class Vehicle {
@Field({ hello: 'world' }) type: string;
@Field({ foo: 5 }) wheels: number;
}
console.log(Field.names(Vehicle)); // ['type', 'wheels']
Plain JavaScript usage
While TypeScript is strongly recommended when using this library, the patterns above can be achieved in plain JavaScript this way:
const { Field } = require('@dgcode/field-manager');
class Vehicle {}
Field.decorate(Vehicle, 'type');
Field.decorate(Vehicle, 'wheels', { foo: 5 });
console.log(Field.names(Vehicle)); // ['type', 'wheels']
FieldManager
Advanced usage: A field manager is a central object maintained along with your decorated classes / instances and that keeps track of all defined fields and options. From the first time you run @Field()
on a class' member, a field manager is maintained along your class.
The most convenient way to access a FieldManager
instance is shown below:
import { Field } from '@dgcode/field-manager';
class Vehicle {
@Field() type: string;
@Field() wheels: number;
}
const manager = Field.manager(Vehicle);
const manager = Field.manager(Vehicle, false); // variant, can return `null` if @Field was never called on Vehicle
or alternatively:
import { Field, FieldManager } from '@dgcode/field-manager';
class Vehicle {
@Field() type: string;
@Field() wheels: number;
}
const manager = FieldManager.fetch(Vehicle); // can be `null` if @Field was never called on Vehicle
const manager = FieldManager.upsert(Vehicle); // variant, always returns a manager
Once you have access to a FieldManager
object, you can run its following helper methods to read metadata about your fields:
List all decorated field names in an array
manager.listFieldNames();
// [ 'type', 'wheels' ]
manager.fieldNames(); // similar, as an Iterator object
List all options passed to decorated field objects
Note that each object listed this way also implements a { fieldName }
property corresponding to said name.
manager.listFields();
// [ { fieldName: 'type' }, { fieldName: 'wheels' } ]
manager.fields(); // similar, as an Iterator object
In any additional metadata was passed earlier via the @Field(data)
decorator, those keys will be available again in the listed fields. See below:
class Vehicle {
@Field({ hello: 'world' }) type: string;
@Field({ foo: 5 }) wheels: number;
}
const manager = Field.manager(Vehicle);
manager.listFields();
// [ { fieldName: 'type', hello: 'world' }, { fieldName: 'wheels', foo: 5 } ]
Count how many fields have been decorated
manager.count();
// 2
Check if a specific field name has been decorated
manager.has('wheels'); // true
manager.has('someUnknownField'); // false
Get the whole options object passed for a specific known field
manager.data('type');
// { fieldName: 'type' }
In any additional metadata was passed ealier via the @Field(data)
decorator, those keys will be available again in the returned object. See below:
class Vehicle {
@Field({ hello: 'world' }) type: string;
@Field({ foo: 5 }) wheels: number;
}
const manager = Field.manager(Vehicle);
manager.data('wheels');
// { fieldName: 'wheels', foo: 5 }
Note this operation is sensitive: ensure the field name is defined first via .has(fieldName)
. If calling .data(...)
on an unknown field name, this method directly throws an Error
.
Get a value from an options object passed for a specific known field
manager.data('type', 'foo');
// 5
This is eventually similar to:
manager.data('type').foo;
// 5
This method is only relevant if you pass additional metadata information to your field calls, such as:
class Vehicle {
@Field({ hello: 'world' }) type: string;
@Field({ foo: 5 }) wheels: number;
}
Note this operation is sensitive: ensure the field name is defined first via .has(fieldName)
. If calling .data(...)
on an unknown field name, this method directly throws an Error
.
Extending
field-manager is designed to be customizable for your own library / application needs without too much hassle.
Generic idea
The magic Field
function provided by this library can be duplicated and customized. It comes with a set of tools to achieve that, the main one being the .fork(...)
function:
const HelloField = Field.fork();
Once generated, your new function can be called just like any other Field decorator:
class Vehicle {
@HelloField() type: string;
@HelloField() wheels: number;
}
or in regular JavaScript:
class Vehicle {}
HelloField.decorate(Vehicle, 'type');
HelloField.decorate(Vehicle, 'wheels');
Default options for decorator calls
Usage:
const HelloField = Field.fork({ hello: 'world' });
Alternative usage (alter defaults once function is already generated):
const HelloField = Field.fork();
HelloField.defaults.hello = 'world';
Doing this ensures that options objects you pass to each decorated field implement at least these properties, even when unspecified. Example:
class Vehicle {
@HelloField() type: string;
// ^ implicitly `@HelloField({ hello: 'world' })`
@HelloField() wheels: number;
// ^ also implicitly `@HelloField({ hello: 'world' })`
@HelloField({ hello: 'foobar' }) motor: string;
// ^ in this case though `hello` is explicitly passed and overrides the default
}
Keep in mind you can pass any number of keys as your field options. Only the relevant keys will be default'ed when not found in your calls.
const HelloField = Field.fork({ hello: 'world' });
class Vehicle {
@HelloField() type: string;
// ^ implicitly `@HelloField({ hello: 'world' })`
@HelloField({ someData: 5 }) wheels: number;
// ^ implicitly `@HelloField({ hello: 'world', someData: 5 })`
@HelloField({ hello: 'foobar', someData: 8 }) motor: string;
// ^ all keys specified here
}
Chaining defaults for shared fields
As showcased above, having multiple decorator functions means we can decorate each individual field in multiple ways:
const HelloField = Field.fork({ hello: 'world' });
const ColorField = Field.fork({ color: 'white' });
class Vehicle {
@HelloField() @ColorField()
type: string;
@HelloField() @ColorField({ color: 'yellow' })
wheels: number;
@HelloField() motor: string;
// ^ this one is not colored!
}
With this example above, all fields tagged this way will share the same FieldManager
instance assigned to Vehicle
:
HelloField.manager(Vehicle) === ColorField.manager(Vehicle); // true
HelloField.manager(Vehicle) === Field.manager(Vehicle); // also true
If we inspect the overall data assigned to each field, this will lead to:
Field.manager(Vehicle).listFields();
// [
// { fieldName: 'type', hello: 'world', color: 'white' },
// { fieldName: 'wheels', hello: 'world', color: 'yellow' },
// { fieldName: 'motor', hello: 'world' }
// ]
FieldManager
s
Separation of concerns with multiple Sometimes storing all data in a single object can bloat information, lead to tight coupling, and make things hard to maintain overall. To the point where we want to explicitly maintain different stores depending on the field kind.
If we re-use our previous example:
const HelloField = Field.fork({ hello: 'world' });
const ColorField = Field.fork({ color: 'white' });
class Vehicle {
@HelloField() @ColorField()
type: string;
@HelloField() @ColorField({ color: 'yellow' })
wheels: number;
@HelloField()
motor: string;
}
both HelloField
and ColorField
will share the same FieldManager
assigned to Vehicle
.
HelloField.manager(Vehicle) === ColorField.manager(Vehicle); // true
ColorField.names(Vehicle);
// [ 'type', 'wheels', 'motor' ]
We can see that 'motor'
has been included even if it has never been tagged with the @ColorField()
decorator. Checking color-tagged fields requires explicitly filtering them:
ColorField.names(Vehicle).filter(fieldName => !!ColorField.data(Vehicle, fieldName, 'color'));
// [ 'type', 'wheels' ]
To properly split fields by tag, we have to maintain a separate manager class in charge of color-tagged fields:
class ColorFieldManager extends FieldManager {};
const HelloField = Field.fork({ hello: 'world' });
const ColorField = Field.fork(ColorFieldManager, { color: 'white' });
class Vehicle {
@HelloField() @ColorField()
type: string;
@HelloField() @ColorField({ color: 'yellow' })
wheels: number;
@HelloField()
motor: string;
}
From now on, all fields tagged with @ColorField()
will be stored in an instance of ColorFieldManager
, no longer in a classic FieldManager
.
ColorField.manager(Vehicle) === HelloField.manager(Vehicle); // false
ColorField.names(Vehicle); // [ 'type', 'wheels', 'motor' ]
HelloField.names(Vehicle); // [ 'type', 'wheels' ]
This also implies that field data specific to each tag is now completely separate from each other. In other words, HelloField
will no longer be aware of the color
property, and ColorField
won't be aware of hello
either:
Field.manager(Vehicle).listFields();
HelloField.manager(Vehicle).listFields();
// [
// { fieldName: 'type', hello: 'world' },
// { fieldName: 'wheels', hello: 'world' },
// { fieldName: 'motor', hello: 'world' }
// ]
ColorField.manager(Vehicle).listFields();
// [
// { fieldName: 'type', color: 'white' },
// { fieldName: 'wheels', color: 'yellow' }
// ]
New field definition hooks
If you implement your own FieldManager
classes, as in the previous example above:
class ColorFieldManager extends FieldManager {};
then you are now also free to detect any new field decoration / update. You have to design the class beforehand to handle these events, with help of the following hooks:
-
onNewField(fieldData, owner)
when a brand new field name is defined; -
onUpdateField(fieldData, owner)
when information about an existing field name has been updated.
In each case:
-
fieldData
is the full field data object. It notably implements the{ fieldName }
information if desired. -
owner
is the current owner of the field. This is very likely the.prototype
of a class (e.g.Vehicle.prototype
), or in some cases a regular object if the decorated field holder is an instance.
Example implementation
Here we generate a field decorator @IntField
which ensures that the decorated field is always assigned as an integer (a TypeError is thrown otherwise).
class IntFieldManager extends FieldManager {
// hook implementation when a new field is set
onNewField(fieldData, prototype) {
const { fieldName } = fieldData;
const privateKey = `__${fieldName}`;
Object.defineProperty(prototype, fieldName, {
get() {
return this[privateKey];
},
set(value) {
if ('number' !== typeof value || Math.round(value) !== value) {
throw new TypeError(`Integer expected for field "${fieldName}"`);
}
this[privateKey] = value
}
});
}
}
Now that the FieldManager is defined, bind it to a custom @Field
implementation, which we name @Int
below:
const Int = Field.fork(IntFieldManager);
class Person {
@Int()
age: number;
@Int()
id: number;
}
We can now check that the fields apply properly by checking a few persons:
const joe = new Person();
joe.age = 5; // ok
joe.id = 'foobar'; // TypeError: Integer expected for field "id"
License
MIT