Skip to main content

Prototypes & Schemas

A prototype is a named object template — useful for type-casting, structured creation, and, paired with #[schema(...)], validation. This is the same mechanism behind typename reporting something other than obj, back in Types & Units.

Declaring a Prototype

#[type] marks the object declaration that follows it as a named template. new TypeName { } creates an instance, filling in whatever defaults the template doesn't override:

declare.stof
#[type]
Customer: {
  str id: ''
  str plan: 'free'

  fn greeting() -> str {
      `Hello, ${self.id}`
  }
}

#[main]
fn main() {
  const c = new Customer { id: 'cust_1' };
  pln(c.plan, c.greeting());
}
Output

The <TypeName> Path Shortcut

<TypeName> is a path directly to the prototype object itself, distinct from any instance of it — the same absolute-path mechanism from The Document Graph, just with its own syntax. Calling a function on it runs with the prototype object as self:

type-path.stof
#[type]
Greeter: {
  str prefix: 'Hello'

  fn greet(name: str) -> str {
      `${self.prefix}, ${name}!`
  }
}

#[main]
fn main() {
  pln(<Greeter>.greet('World'));
  pln(<Greeter>.prefix);
}
Output

Constructors

#[constructor] marks a function to run automatically on every new instance:

constructor.stof
#[type]
Point2D: {
  float x: 0
  float y: 0

  #[constructor]
  fn init() {
      self.initialized = true;
  }
}

#[main]
fn main() {
  const p = new Point2D { x: 1, y: 2 };
  pln(p.x, p.y, p.initialized);
}
Output

Inheritance with #[extends]

A prototype can extend another by name or by reference — the base type's constructor always runs first:

extends.stof
#[type]
Point2D: {
  float x: 0
  float y: 0

  #[constructor]
  fn init() {
      self.isapoint = true;
  }
}

#[type]
#[extends('Point2D')]
Point: {
  float z: 0

  #[constructor]
  fn init() {
      self.initialized = true;
  }
}

#[main]
fn main() {
  const p = new Point { x: 1, y: 2, z: 3 };
  pln(p.isapoint, p.initialized, p.z);
}
Output

Inheritance is just a type chain, so easier to just cast directly:

extends-directly.stof
#[type]
Point2D: {
  float x: 0
  float y: 0

  fn length() -> m { Num.sqrt(self.x.pow(2) + self.y.pow(2)) }
}

#[type]
Point2D Point: {
  float z: 0

  fn length() -> m { Num.sqrt(self.x.pow(2) + self.y.pow(2) + self.z.pow(2)) }
}

#[main]
fn main() {
  const p = new Point { x: 1m, y: 2ft, z: 300cm };
  pln(str(p.length().round(2)), str(p.length<Point2D>().round(2)));
}
Output

Auto-Casting Typed Fields

A field declared with a prototype type auto-casts a plain new { } object assigned to it, merging in whatever defaults the prototype provides:

auto-cast.stof
#[type]
SubType: {
  str one: 'one'
  str two: 'two'
}

#[type]
SuperType: {
  SubType sub: {}
  str msg: ''
}

#[main]
fn main() {
  const o = new SuperType {
      msg: 'hi',
      sub: new { one: 'ONE' }
  };
  pln(typename o.sub, o.sub.one, o.sub.two);
}
Output

o.sub only specified onetwo still came from SubType's own default.

Schemas: Validating with #[schema]

#[schema((target_value: T): bool => ...)] on a field attaches a validator; schemafy(target) checks an object against every field that has one:

schema-basic.stof
#[type]
FirstSchema: {
  #[schema((target_value: str): bool => target_value.len() > 2)]
  first: "First"
}

#[main]
fn main() {
  const target = new { first: "Bob" };
  pln(<FirstSchema>.schemafy(target));

  target.first = "AJ";
  pln(<FirstSchema>.schemafy(target));
}
Output

Combining Validators

A list of functions on #[schema(...)] runs as a pipeline — each has to pass, in order, with short-circuiting:

schema-pipeline.stof
fn is_string(target_val: unknown) -> bool {
  "str" == typeof target_val
}

email_validation: [
  self.is_string,
  (target_val: str): bool => target_val.contains("@"),
]

#[schema(self.email_validation)]
email: "info@example.com"

#[main]
fn main() {
  const target = new { email: "notvalid" };
  pln(self.schemafy(target));

  target.email = "info@stof.dev";
  pln(self.schemafy(target));
}
Output

Sub-Schemas

A bare #[schema], with no function, tells schemafy to recurse into that field if the target's value is also an object:

sub-schema.stof
#[type]
Schema: {
  #[schema]
  sub: {
      #[schema((target_val: int): bool => target_val >= 0)]
      field: 0
  }
}

#[main]
fn main() {
  const target = new {
      sub: new { field: -42 }
  };
  pln(self.Schema.schemafy(target));
}
Output

Cleaning Up with remove_invalid

schemafy can also modify the target directly — remove_invalid = true strips any field that failed validation instead of just reporting the failure. (remove_undefined = true is the sibling option, for stripping fields the schema doesn't mention at all.)

schema-cleanup.stof
fn is_string(target_val: unknown) -> bool {
  "str" == typeof target_val
}

#[schema(self.is_string)]
label: "default"

#[main]
fn main() {
  const target = new { label: 42 };
  pln(self.schemafy(target, remove_invalid = true));
  pln(target.label);
}
Output