For contributors adding a new built-in type to the engine — a step-by-step recipe.
- 10-step recipe — Value type, built-in registration, class value subclass, engine/runtime integration, constants, structuredClone, tests, benchmarks, documentation
- Key patterns — Shared prototype singleton (GC-pinned),
ThisValuefor method callbacks (notSelf),TraceReferencesfor GC - Engine/runtime integration — Core language built-ins are registered by the engine; runtime globals and tool-specific runtime APIs belong in concrete runtime extension classes
- Checklist included — Complete checklist at the end of the document for verification
This guide walks through every step needed to add a new built-in type to GocciaScript. Follow the steps in order; each section references the exact files and patterns involved.
Before you start: built-in prototype objects are not module-level singletons. They live in a per-engine realm (Goccia.Realm.pas) so that two engines on the same worker thread see independent intrinsics. Your value type registers a realm slot at unit initialization time and stores its shared prototype in that slot — never in a threadvar, class var, or static singleton, because cached pointers go stale across engine recreation. The slot pattern is shown in Step 1 below.
Adding a built-in type like Set, Map, or ArrayBuffer requires changes across several files:
| Step | File(s) | Purpose |
|---|---|---|
| 1 | source/units/Goccia.Values.YourValue.pas |
Value type class (data storage, prototype methods) |
| 2 | source/units/Goccia.Builtins.GlobalYour.pas |
Built-in registration (constructor function, static methods) |
| 3 | source/units/Goccia.Values.ClassValue.pas |
Class value subclass (CreateNativeInstance) |
| 4 | source/units/Goccia.Engine.pas |
Engine integration (enum, globals, registration, cleanup) |
| 5 | source/units/Goccia.Constants.ConstructorNames.pas |
Constructor name constant |
| 6 | source/units/Goccia.Constants.PropertyNames.pas |
Property name constants (if needed) |
| 7 | source/units/Goccia.Builtins.Globals.pas |
structuredClone support (if cloneable) |
| 8 | tests/built-ins/YourType/ |
JavaScript tests |
| 9 | benchmarks/yourtype.js |
Benchmarks |
| 10 | docs/built-ins.md, README.md, other docs/* as needed |
Documentation |
Create a new unit inheriting from TGocciaInstanceValue. This is the runtime representation of your type.
unit Goccia.Values.YourValue;
{$I Goccia.inc}
interface
uses
SysUtils,
Goccia.Arguments.Collection,
Goccia.SharedPrototype,
Goccia.Values.ClassValue,
Goccia.Values.ObjectValue,
Goccia.Values.Primitives;
type
TGocciaYourValue = class(TGocciaInstanceValue)
private
class var FShared: TGocciaSharedPrototype;
private
// Internal data storage
FData: ...; // e.g., TBytes, TGocciaValueList, etc.
// Prototype method implementations (use ThisValue, not Self)
function YourMethod(const AArgs: TGocciaArgumentsCollection;
const AThisValue: TGocciaValue): TGocciaValue;
procedure InitializePrototype;
public
constructor Create(const AClass: TGocciaClassValue = nil);
destructor Destroy; override;
function GetProperty(const AName: string): TGocciaValue; override;
function ToStringTag: string; override;
procedure InitializeNativeFromArguments(
const AArguments: TGocciaArgumentsCollection); override;
procedure TraceReferences; override;
class procedure ExposePrototype(const AConstructor: TGocciaValue);
end;
implementation
uses
Goccia.Constants.ConstructorNames,
Goccia.Constants.PropertyNames,
Goccia.GarbageCollector,
Goccia.Values.ErrorHelper,
Goccia.Values.NativeFunction,
Goccia.Values.ObjectPropertyDescriptor,
Goccia.Values.SymbolValue;
constructor TGocciaYourValue.Create(const AClass: TGocciaClassValue = nil);
begin
inherited Create(AClass);
// Initialize internal data
InitializePrototype;
if not Assigned(AClass) and Assigned(FShared) then
FPrototype := FShared.Prototype;
end;
destructor TGocciaYourValue.Destroy;
begin
// Free owned data (non-GC-managed)
inherited;
end;Shared prototype singleton (realm-owned) -- All instances of one engine share a single prototype object that lives in the engine's realm. Register a realm-owned slot at unit initialization time and look up the shared prototype through it on every call. Do not store FShared in a class var or threadvar — cached pointers survive engine destruction and become dangling references on the next engine.
var
GYourSharedSlot: TGocciaRealmOwnedSlotId;
function GetYourShared: TGocciaSharedPrototype; inline;
begin
if Assigned(CurrentRealm) then
Result := TGocciaSharedPrototype(CurrentRealm.GetOwnedSlot(GYourSharedSlot))
else
Result := nil;
end;
procedure TGocciaYourValue.InitializePrototype;
var
Shared: TGocciaSharedPrototype;
Members: TGocciaMemberCollection;
Definitions: array of TGocciaMemberDefinition;
begin
if not Assigned(CurrentRealm) then Exit;
if Assigned(GetYourShared) then Exit;
Shared := TGocciaSharedPrototype.Create(Self);
Members := TGocciaMemberCollection.Create;
try
Members.AddNamedMethod('methodName', YourMethod, 1);
Members.AddSymbolMethod(
TGocciaSymbolValue.WellKnownIterator,
'[Symbol.iterator]', YourIteratorMethod, 0,
[pfConfigurable, pfWritable]);
Members.AddAccessor(
'propertyName', GetterMethod, nil, [pfConfigurable]);
Definitions := Members.ToDefinitions;
Definitions[0].MemberFlags := [gmfNoFunctionPrototype];
finally
Members.Free;
end;
RegisterMemberDefinitions(Shared.Prototype, Definitions);
CurrentRealm.SetOwnedSlot(GYourSharedSlot, Shared);
end;
initialization
GYourSharedSlot := RegisterRealmOwnedSlot('YourType.SharedPrototype');The Goccia.Realm unit owns RegisterRealmOwnedSlot, CurrentRealm, and the TGocciaRealmOwnedSlotId type — add it to your uses clause. For prototypes that are plain TGocciaObjectValue instances (no TGocciaSharedPrototype wrapper), use RegisterRealmSlot and SetSlot/GetSlot instead — see Core patterns § Realm Ownership & Slot Registration for the raw-slot variant. TGocciaSharedPrototype.Destroy unpins both FPrototype and FMethodHost, so realm tear-down releases the prototype graph atomically.
The preferred pattern is:
- keep runtime semantics on the value type
- keep constructor/static methods on the built-in wrapper
- declare exposed members with
Define*helpers, ideally viaTGocciaMemberCollection - register them via
RegisterMemberDefinitions
This keeps the JS-visible surface in one place and avoids repeating RegisterNativeMethod, DefineProperty, and CreateWithoutPrototype boilerplate in every type.
Member definitions used only during construction belong in local arrays, or can
be passed directly from Members.ToDefinitions while the collection is alive.
RegisterMemberDefinitions copies the callbacks, values, names, and flags into
the target's functions and descriptors; it does not retain the definition array.
These temporary arrays do not need thread-local storage or thread cleanup hooks.
ExposePrototype -- Must NOT free the created instance (it becomes the pinned method host). Look up the shared prototype through the realm rather than caching it:
class procedure TGocciaYourValue.ExposePrototype(
const AConstructor: TGocciaValue);
var
Shared: TGocciaSharedPrototype;
begin
Shared := GetYourShared;
if not Assigned(Shared) then
begin
TGocciaYourValue.Create; // populates the realm slot, do NOT call .Free
Shared := GetYourShared;
end;
ExposeSharedPrototypeOnConstructor(Shared, AConstructor);
end;Prototype method callbacks -- Always use AThisValue (the receiver), never Self (the method host singleton):
function TGocciaYourValue.YourMethod(
const AArgs: TGocciaArgumentsCollection;
const AThisValue: TGocciaValue): TGocciaValue;
var
Obj: TGocciaYourValue;
begin
if not (AThisValue is TGocciaYourValue) then
ThrowTypeError('...');
Obj := TGocciaYourValue(AThisValue);
// Work with Obj, not Self
end;GetProperty -- Override to handle computed properties (like size, byteLength):
function TGocciaYourValue.GetProperty(const AName: string): TGocciaValue;
begin
if AName = PROP_SIZE then
Result := TGocciaNumberLiteralValue.Create(FItems.Count)
else
Result := inherited GetProperty(AName);
end;TraceReferences -- Mark all TGocciaValue references held by this object. The collector calls it after marking the object, so it has no GCMarked guard, and it may run more than once in a collection, so it must be idempotent (ADR 0147):
procedure TGocciaYourValue.TraceReferences;
var
I: Integer;
begin
inherited;
// Mark each TGocciaValue reference
for I := 0 to FItems.Count - 1 do
if Assigned(FItems[I]) then
FItems[I].MarkReferences;
end;If your type only holds non-TGocciaValue data (e.g., TBytes), the body can just call inherited.
ToStringTag -- Return the constructor name:
function TGocciaYourValue.ToStringTag: string;
begin
Result := CONSTRUCTOR_YOUR;
end;ToStringTag names the value for the engine's own display (error details, snapshots). It does not decide what Object.prototype.toString returns: that tag comes only from the internal slots ECMA-262 lists, and otherwise from Symbol.toStringTag read through the prototype chain. Give the prototype a Symbol.toStringTag data property (Members.AddSymbolDataProperty(TGocciaSymbolValue.WellKnownToStringTag, ..., [pfConfigurable])), or the type prints as [object Object].
InitializeNativeFromArguments -- Called when new YourType(args) is used via the class constructor path, including a subclass's super(args). It runs after the instance already has its final prototype (new.target.prototype, or your type's own prototype when that is not an object), so it may read methods off the instance, as Map reads its set adder. A type whose constructor validates its arguments before it reads new.target.prototype is listed in ShouldDelayNativePrototypeLookup instead: it gets its prototype after this call and before FinalizeNativeFromArguments.
procedure TGocciaYourValue.InitializeNativeFromArguments(
const AArguments: TGocciaArgumentsCollection);
begin
// Validate and process constructor arguments
end;This unit creates the constructor function and any static methods.
unit Goccia.Builtins.GlobalYour;
{$I Goccia.inc}
interface
uses
Goccia.Arguments.Collection,
Goccia.Builtins.Base,
Goccia.Error.ThrowErrorCallback,
Goccia.Scope,
Goccia.Values.NativeFunction,
Goccia.Values.Primitives,
Goccia.Values.YourValue;
type
TGocciaGlobalYour = class(TGocciaBuiltin)
private
FYourConstructor: TGocciaNativeFunctionValue;
function YourConstructorFn(const AArgs: TGocciaArgumentsCollection;
const AThisValue: TGocciaValue): TGocciaValue;
public
constructor Create(const AName: string; const AScope: TGocciaScope;
const AThrowError: TGocciaThrowErrorCallback);
end;
implementation
uses
Goccia.Values.ErrorHelper;
constructor TGocciaGlobalYour.Create(const AName: string;
const AScope: TGocciaScope;
const AThrowError: TGocciaThrowErrorCallback);
begin
inherited Create(AName, AScope, AThrowError);
FYourConstructor := TGocciaNativeFunctionValue.Create(
YourConstructorFn, 'YourType', 1);
TGocciaYourValue.ExposePrototype(FYourConstructor);
// Register static methods on FBuiltinObject
FBuiltinObject.RegisterNativeMethod(
TGocciaNativeFunctionValue.CreateWithoutPrototype(
StaticMethod, 'staticMethod', 1));
end;
function TGocciaGlobalYour.YourConstructorFn(
const AArgs: TGocciaArgumentsCollection;
const AThisValue: TGocciaValue): TGocciaValue;
begin
// Validate arguments and create instance
Result := TGocciaYourValue.Create;
end;Add a class value subclass so new YourType() works via the standard class instantiation path.
In the interface section (after existing class values):
TGocciaYourClassValue = class(TGocciaClassValue)
function CreateNativeInstance(
const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue; override;
end;In the implementation section:
{ TGocciaYourClassValue }
function TGocciaYourClassValue.CreateNativeInstance(
const AArguments: TGocciaArgumentsCollection): TGocciaObjectValue;
begin
Result := TGocciaYourValue.Create;
end;In the implementation uses clause, add Goccia.Values.YourValue.
Core language built-ins belong in Goccia.Engine.pas. Runtime globals that are not part of the language core belong in a dedicated runtime extension unit under source/units/Goccia.RuntimeExtensions.*.pas.
For a core language built-in, make these changes in the engine:
Add Goccia.Builtins.GlobalYour (alphabetically sorted).
Core language built-ins are always registered and do not need a runtime extension. Runtime globals and tool-specific runtime APIs should add a concrete extension class:
TGocciaYourRuntimeExtension = class(TGocciaRuntimeExtension)
public
procedure Attach(const ARuntime: TGocciaRuntimeCore); override;
procedure Detach; override;
end;Most new language built-in types should skip this step entirely. Runtime extension membership is represented by installed classes and profiles, not by engine configuration.
FBuiltinYour: TGocciaGlobalYour;For core language built-ins (no flag-gating needed):
FBuiltinYour := TGocciaGlobalYour.Create(
CONSTRUCTOR_YOUR, Scope, ThrowError);Runtime globals do not use a selector check in the engine. Create and register them from the concrete runtime extension's Attach method, using ARuntime.Engine.GlobalScope and ARuntime.Engine.ThrowError; free extension-owned built-ins from Detach. If the extension exposes a JS-visible runtime global name, also call Runtime.RegisterRuntimeGlobalName('YourType') from Attach so the runtime-globals list on the Goccia namespace stays accurate.
begin
TypeDef.ConstructorName := CONSTRUCTOR_YOUR;
TypeDef.Kind := gtdkNativeInstanceType;
TypeDef.ClassValueClass := TGocciaYourClassValue;
TypeDef.ExposePrototype := @ExposeYourPrototype;
TypeDef.PrototypeProvider := nil;
TypeDef.StaticSource := BuiltinObjectOrNil(FBuiltinYour);
TypeDef.PrototypeParent := ObjectConstructor.Prototype;
TypeDef.AddSpeciesGetter := False;
RegisterTypeDefinition(FGlobalScope, TypeDef, SpeciesGetter,
GenericConstructor);
YourConstructor := TGocciaYourClassValue(GenericConstructor);
end;For built-ins that use an existing shared prototype rather than ExposePrototype, set ExposePrototype := nil and provide PrototypeProvider := @YourPrototypeProvider.
FBuiltinYour.Free;Add Goccia.Values.YourValue (alphabetically sorted).
property BuiltinYour: TGocciaGlobalYour read FBuiltinYour;For a runtime global, use the same built-in/value unit patterns but wire it through a concrete runtime extension instead of Goccia.Engine.pas:
- Add a dedicated runtime-extension unit in
source/units/, following theGoccia.RuntimeExtensions.<Feature>naming pattern. - Add a private built-in field to the concrete extension class.
- Instantiate it from
Attachand free it fromDetach, mirroring the engine's built-in / constructor-registration pattern but usingRuntime.Engine. - If the feature adds importable file types, override
AddModuleExtensionsandTryLoadModuleso the extension participates only when installed. Read the file only through theAReadContentreaderTryLoadModulereceives, never from the content provider directly: the reader verifies provider-package files against their pins.Goccia.Engine.ProviderImports.Test.paschecks the result for every file extension the loader profile registers: it fails until the new file extension has an entry inRUNTIME_MODULE_FIXTURES, and then expects a tampered package file of that type to be refused. It does not reach a runtime extension installed outside the loader profile, or a loader that claims a file extension it did not register. Name theASpecifierit receives, the request as the import wrote it, in any load or parse error, never the resolved path (ADR 0108). - If the feature should be part of a CLI surface, add it to the relevant profile in
Goccia.RuntimeProfiles.*.pas; otherwise embedders can install the concrete extension directly.
procedure TGocciaYourRuntimeExtension.Attach(const ARuntime: TGocciaRuntimeCore);
begin
inherited Attach(ARuntime);
FBuiltinYour := TGocciaGlobalYour.Create(CONSTRUCTOR_YOUR,
Runtime.Engine.GlobalScope, Runtime.Engine.ThrowError);
Runtime.RegisterRuntimeGlobalName(CONSTRUCTOR_YOUR);
end;
procedure TGocciaYourRuntimeExtension.Detach;
begin
FBuiltinYour.Free;
FBuiltinYour := nil;
inherited;
end;Profiles install extensions by class:
procedure ApplyLoaderRuntimeProfile(const ARuntime: TGocciaRuntimeCore);
begin
ARuntime.Install(TGocciaYourRuntimeExtension.Create);
end;In source/units/Goccia.Constants.ConstructorNames.pas:
CONSTRUCTOR_YOUR = 'YourType';In source/units/Goccia.Constants.PropertyNames.pas:
PROP_YOUR_PROPERTY = 'yourProperty';In source/units/Goccia.Builtins.Globals.pas:
Goccia.Values.YourValue,function CloneYour(const AObj: TGocciaYourValue;
const AMemory: TDictionary<TGocciaValue, TGocciaValue>): TGocciaYourValue;
begin
Result := TGocciaYourValue.Create;
AMemory.Add(AObj, Result);
// Copy internal data from AObj to Result
end;Add before the TGocciaObjectValue catch-all branch:
else if AValue is TGocciaYourValue then
Result := CloneYour(TGocciaYourValue(AValue), AMemory)The order matters -- specific types must be checked before TGocciaObjectValue since they inherit from it.
Create test files under tests/built-ins/YourType/. Follow the file layout conventions from testing.md:
- One method per file — each prototype method, static method, and constructor variant gets its own test file.
- Prototype methods in
prototype/— instance methods live inYourType/prototype/methodName.js. - Static methods at the top level —
YourType/staticMethod.js(no separatestatic/folder). - Edge cases are co-located — NaN handling, boundary conditions, error cases belong in the same file as the happy-path tests for that method. Do not create a separate
edge-cases.js.
tests/built-ins/YourType/
constructor.js # new YourType(...) constructor variants + error cases
toString-tag.js # Symbol.toStringTag
from.js, of.js # Static methods at top level
prototype/ # Instance methods — one file per method
methodA.js # Happy paths + edge cases for methodA
methodB.js # Happy paths + edge cases for methodB
Each file uses the built-in test framework:
describe("YourType constructor", () => {
test("creates an instance", () => {
const obj = new YourType();
expect(obj instanceof YourType).toBe(true);
});
test("throws RangeError for invalid argument", () => {
expect(() => new YourType(-1)).toThrow(RangeError);
});
});If the type supports structuredClone, add tests/built-ins/structuredClone/yourtype.js.
Create benchmarks/yourtype.js:
/*---
description: YourType operation benchmarks
---*/
import { bench, group } from "goccia:microbench";
group("YourType creation", () => {
bench("create YourType", () => {
const obj = new YourType();
});
});
group("YourType methods", () => {
bench("method call", ({ *setup() {
const obj = new YourType();
yield () => {
obj.method();
};
} }).setup);
});Add a section with method tables:
### YourType (`Goccia.Builtins.GlobalYour.pas`)
| Method/Property | Description |
|--------|-------------|
| `new YourType(args)` | Create a new instance |
| `yourType.method()` | Description |
| `yourType.property` | Description |Add YourType to the built-in objects list.
- Update docs/built-ins.md and README.md as needed.
- If you introduce a new major engine layer or file-level component worth listing, update docs/architecture.md (or the doc index in CONTRIBUTING.md) instead of maintaining a separate table elsewhere.
Use this checklist when adding a new built-in type:
- Value type unit (
Goccia.Values.YourValue.pas) with realm slot registered ininitialization - Built-in registration unit (
Goccia.Builtins.GlobalYour.pas) - Class value subclass in
Goccia.Values.ClassValue.pas - Runtime: concrete
TGocciaRuntimeExtensionclass (runtime globals only) - Engine: field declaration
- Engine:
RegisterBuiltInsregistration - Engine:
RegisterBuiltinConstructorsconstructor - Engine:
Destroycleanup - Engine: interface and implementation uses clauses
- Constructor name constant in
Goccia.Constants.ConstructorNames.pas - Property name constants in
Goccia.Constants.PropertyNames.pas(if needed) - structuredClone support in
Goccia.Builtins.Globals.pas(if cloneable) - JavaScript tests in
tests/built-ins/YourType/ - Benchmarks in
benchmarks/yourtype.js - Documentation in
docs/built-ins.md,README.md, and other docs as needed (see CONTRIBUTING.md) - All existing tests still pass
- Build succeeds (
./build.pas --clean runner)
- Shared prototype is pinned automatically by
TGocciaSharedPrototype.Createand unpinned byTGocciaSharedPrototype.Destroy. Registering the helper in a realm-owned slot ties its lifetime to the engine's realm, so tear-down releases everything atomically. - Realm slots pin the values they hold.
RegisterRealmSlot(forTGCManagedObjectprototypes) pins onSetSlotand unpins at realm tear-down;RegisterRealmOwnedSlot(for plain-TObjecthelpers) calls.Freeat tear-down before pin release. TraceReferencesmust callMarkReferenceson everyTGocciaValuefield reachable from the instance. If you only hold non-value data (e.g.,TBytes), callinginheritedis sufficient.ExposePrototypecreates a sentinel instance that becomes the method host. Do NOT free this instance -- it is pinned by the GC and owned by the realm.- Temp roots: If you hold
TGocciaValuereferences in Pascal variables during a long operation (not in any scope), protect them withAddTempRoot/RemoveTempRoot. - Non-owning lists: Use
TObjectList<T>.Create(False)for lists that store GC-managed values.
- Freeing the ExposePrototype sentinel -- Causes access violations when prototype methods are called.
- Using
Selfin prototype callbacks --Selfis the method host singleton, not the instance. Always useAThisValue. - Caching realm-scoped objects in
threadvars or class vars -- The cached pointer survives engine destruction and becomes a dangling reference on the next engine. Always read the shared prototype throughCurrentRealm.GetSlot/GetOwnedSlot. See Core patterns § Realm Ownership & Slot Registration for the stale-cache antipattern. IsInfinityvsIsInfinite--IsInfinitychecks only positive infinity. UseIsInfiniteto check both positive and negative.- Circular interface dependencies -- If your value type and
ClassValueneed each other, put the value type inClassValue's implementation uses clause (not interface). - Stale build artifacts -- After adding new units, run
./build.pas --clean runnerto avoid FPC internal errors from stale.ppufiles. - Use property accessors for special numbers --
TGocciaNumberLiteralValuestores a singleDoubleinFValueusing standard IEEE 754 bit patterns. Use theIsNaN,IsInfinity, andIsNegativeZeroproperty accessors rather than rawFValuecomparisons when you need to distinguish special values. For negative zero specifically,IsNegativeZerouses an endian-neutral sign-bit check. SeeGoccia.Values.TypedArrayValue.WriteNumberLiteralandGoccia.Arithmetic.pasfor the canonical patterns.