Skip to content

Data models ​

The schema types itself ​

ts
import { BaseTypeDataModel, fields } from '@vttforge/core';

const defineCharacterSchema = () => {
  const f = fields();
  return {
    level: new f.NumberField({ required: true, nullable: false, initial: 1 }),
    health: new f.SchemaField({
      value: new f.NumberField({ required: true, nullable: false, initial: 10 }),
      max: new f.NumberField({ required: true, nullable: false, initial: 10 }),
    }),
  };
};

export class CharacterData extends BaseTypeDataModel(defineCharacterSchema) {
  declare armorClass: number;

  prepareDerivedData() {
    this.armorClass = 10 + this.level; // this.level is number
  }
}

type CharacterSystem = CharacterData['$inferData'];

You write the schema once. There is no second type declaration to keep in sync, and this.level inside prepareDerivedData is a number.

It has to be a function: fields() reads a Foundry global that does not exist when your module is first evaluated.

Declare your derived values ​

armorClass is not in the schema, so it is not on the type. Declaring it puts it on the type as a value that exists once prepareDerivedData has run.

In JavaScript there is no declare, and a plain class field would emit and reset the property to undefined after every data preparation. Put derived values in the schema instead, as a NumberField with initial: 0, and assign them in prepareDerivedData. The scaffold's JavaScript template does this for the ability modifiers.

Resources and token bars ​

A resource is { value, max }: hit points, power, ammunition, the shape a token bar reads. resourceField() builds that SchemaField with both children required, non-nullable numbers, so system.health.value is a number everywhere:

ts
import { 
fields
,
resourceField
} from '@vttforge/core';
const
defineCharacterSchema
= () => {
const
f
=
fields
();
return {
health
:
resourceField
({
initial
: 10 }),
power
:
resourceField
({
initial
: 5,
max
: 5,
label
: 'MY_SYSTEM.Power' }),
level
: new
f
.
NumberField
({
required
: true,
nullable
: false,
initial
: 1 }),
}; };
OptionDefaultWhat it sets
initial0starting value
maxinitialstarting max
min0lowest allowed for both
integertruewhole numbers only
label, hintnonethe SchemaField's own

Point primaryTokenAttribute and secondaryTokenAttribute in the manifest at the field key, health and power here, or a dotted path such as attributes.hp. Foundry draws no bar for a path without both keys and says nothing about it, so registerSystem checks the two manifest paths against the Actor data models it registers and throws VTTF-0010 at init when no model has a resource there. vttforge audit runs the same check on the source (rule 007) and reads resourceField() as a resource.

Nullability defaults ​

Every field class picks its own defaults, and they disagree.

FieldWith no optionsWhy
NumberFieldnumber | null | undefinedoptional and nullable by default
StringFieldstring | undefinedoptional
BooleanFieldbooleanrequired, starts at false
HTMLFieldstringrequired, blank-friendly
ColorFieldColor | nullstarts at null
FilePathFieldstring | nullstarts at null
ArrayField / SetFieldnever absentrequired, builds its own empty value

So new f.NumberField() is not a number. Pass the options explicitly:

ts
new f.NumberField({ required: true, nullable: false, initial: 0 })

The inference reads the literal types of what you pass. An options object held in a variable widens nullable: false to boolean, and the field's own default applies again. Pin it with as const, or build the field in a small factory so the literals stay inline.

Fields that are not what they look like ​

FieldHolds
ColorFielda Color instance, not a string
SetFielda Set, not an array
ForeignDocumentFieldthe document; the model installs it as a getter
EmbeddedDataFieldthe model instance, with its derived data
TypedSchemaFielda union you can narrow on type

MIT licensed.