Adding a New Built-in Type
For contributors adding a new built-in type to the engine — a step-by-step recipe.
Executive Summary
- 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),MarkReferencesfor 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.
Overview
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 |
Step 1: Value Type (Goccia.Values.YourValue.pas)
Create a new unit inheriting from TGocciaInstanceValue. This is the runtime representation of your type.
Template
unit Goccia.Values.YourValue;{$I Goccia.inc}interfaceusesSysUtils,Goccia.Arguments.Collection,Goccia.SharedPrototype,Goccia.Values.ClassValue,Goccia.Values.ObjectValue,Goccia.Values.Primitives;typeTGocciaYourValue = class(TGocciaInstanceValue)privateclass var FShared: TGocciaSharedPrototype;private// Internal data storageFData: ...; // e.g., TBytes, TGocciaValueList, etc.// Prototype method implementations (use ThisValue, not Self)function YourMethod(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;procedure InitializePrototype;publicconstructor 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 MarkReferences; override;class procedure ExposePrototype(const AConstructor: TGocciaValue);end;implementationusesGoccia.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);begininherited Create(AClass);// Initialize internal dataInitializePrototype;if not Assigned(AClass) and Assigned(FShared) thenFPrototype := FShared.Prototype;end;destructor TGocciaYourValue.Destroy;begin// Free owned data (non-GC-managed)inherited;end;
Key Patterns
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.
varGYourSharedSlot: TGocciaRealmOwnedSlotId;function GetYourShared: TGocciaSharedPrototype; inline;beginif Assigned(CurrentRealm) thenResult := TGocciaSharedPrototype(CurrentRealm.GetOwnedSlot(GYourSharedSlot))elseResult := nil;end;procedure TGocciaYourValue.InitializePrototype;varShared: TGocciaSharedPrototype;Members: TGocciaMemberCollection;Definitions: array of TGocciaMemberDefinition;beginif not Assigned(CurrentRealm) then Exit;if Assigned(GetYourShared) then Exit;Shared := TGocciaSharedPrototype.Create(Self);Members := TGocciaMemberCollection.Create;tryMembers.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];finallyMembers.Free;end;RegisterMemberDefinitions(Shared.Prototype, Definitions);CurrentRealm.SetOwnedSlot(GYourSharedSlot, Shared);end;initializationGYourSharedSlot := 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);varShared: TGocciaSharedPrototype;beginShared := GetYourShared;if not Assigned(Shared) thenbeginTGocciaYourValue.Create; // populates the realm slot, do NOT call .FreeShared := 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;varObj: TGocciaYourValue;beginif not (AThisValue is TGocciaYourValue) thenThrowTypeError('...');Obj := TGocciaYourValue(AThisValue);// Work with Obj, not Selfend;
GetProperty -- Override to handle computed properties (like size, byteLength):
function TGocciaYourValue.GetProperty(const AName: string): TGocciaValue;beginif AName = PROP_SIZE thenResult := TGocciaNumberLiteralValue.Create(FItems.Count)elseResult := inherited GetProperty(AName);end;
MarkReferences -- Mark all TGocciaValue references held by this object:
procedure TGocciaYourValue.MarkReferences;varI: Integer;beginif GCMarked then Exit;inherited;// Mark each TGocciaValue referencefor I := 0 to FItems.Count - 1 doif Assigned(FItems[I]) thenFItems[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;beginResult := 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 argumentsend;
Step 2: Built-in Registration (Goccia.Builtins.GlobalYour.pas)
This unit creates the constructor function and any static methods.
unit Goccia.Builtins.GlobalYour;{$I Goccia.inc}interfaceusesGoccia.Arguments.Collection,Goccia.Builtins.Base,Goccia.Error.ThrowErrorCallback,Goccia.Scope,Goccia.Values.NativeFunction,Goccia.Values.Primitives,Goccia.Values.YourValue;typeTGocciaGlobalYour = class(TGocciaBuiltin)privateFYourConstructor: TGocciaNativeFunctionValue;function YourConstructorFn(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;publicconstructor Create(const AName: string; const AScope: TGocciaScope;const AThrowError: TGocciaThrowErrorCallback);end;implementationusesGoccia.Values.ErrorHelper;constructor TGocciaGlobalYour.Create(const AName: string;const AScope: TGocciaScope;const AThrowError: TGocciaThrowErrorCallback);begininherited Create(AName, AScope, AThrowError);FYourConstructor := TGocciaNativeFunctionValue.Create(YourConstructorFn, 'YourType', 1);TGocciaYourValue.ExposePrototype(FYourConstructor);// Register static methods on FBuiltinObjectFBuiltinObject.RegisterNativeMethod(TGocciaNativeFunctionValue.CreateWithoutPrototype(StaticMethod, 'staticMethod', 1));end;function TGocciaGlobalYour.YourConstructorFn(const AArgs: TGocciaArgumentsCollection;const AThisValue: TGocciaValue): TGocciaValue;begin// Validate arguments and create instanceResult := TGocciaYourValue.Create;end;
Step 3: Class Value Subclass (Goccia.Values.ClassValue.pas)
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;beginResult := TGocciaYourValue.Create;end;
In the implementation uses clause, add Goccia.Values.YourValue.
Step 4: Engine Or Runtime Integration
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:
4a. Interface uses clause
Add Goccia.Builtins.GlobalYour (alphabetically sorted).
4b. Runtime extension class (runtime globals only)
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)publicprocedure 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.
4c. Field declaration
FBuiltinYour: TGocciaGlobalYour;
4d. RegisterBuiltIns
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.Interpreter.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.
4e. RegisterBuiltinConstructors
beginTypeDef.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(FInterpreter.GlobalScope, 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.
4f. Destructor
FBuiltinYour.Free;
4g. Implementation uses clause
Add Goccia.Values.YourValue (alphabetically sorted).
4h. Property (optional)
property BuiltinYour: TGocciaGlobalYour read FBuiltinYour;
4i. Runtime-extension registration
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);begininherited Attach(ARuntime);FBuiltinYour := TGocciaGlobalYour.Create(CONSTRUCTOR_YOUR,Runtime.Engine.Interpreter.GlobalScope, Runtime.Engine.ThrowError);Runtime.RegisterRuntimeGlobalName(CONSTRUCTOR_YOUR);end;procedure TGocciaYourRuntimeExtension.Detach;beginFBuiltinYour.Free;FBuiltinYour := nil;inherited;end;
Profiles install extensions by class:
procedure ApplyLoaderRuntimeProfile(const ARuntime: TGocciaRuntimeCore);beginARuntime.Install(TGocciaYourRuntimeExtension.Create);end;
Step 5: Constructor Name Constant
In source/units/Goccia.Constants.ConstructorNames.pas:
CONSTRUCTOR_YOUR = 'YourType';
Step 6: Property Name Constants (if needed)
In source/units/Goccia.Constants.PropertyNames.pas:
PROP_YOUR_PROPERTY = 'yourProperty';
Step 7: structuredClone Support (if cloneable)
In source/units/Goccia.Builtins.Globals.pas:
7a. Add to implementation uses clause
Goccia.Values.YourValue,
7b. Add clone function
function CloneYour(const AObj: TGocciaYourValue;const AMemory: TDictionary<TGocciaValue, TGocciaValue>): TGocciaYourValue;beginResult := TGocciaYourValue.Create;AMemory.Add(AObj, Result);// Copy internal data from AObj to Resultend;
7c. Add branch in StructuredCloneValue
Add before the TGocciaObjectValue catch-all branch:
else if AValue is TGocciaYourValue thenResult := CloneYour(TGocciaYourValue(AValue), AMemory)
The order matters -- specific types must be checked before TGocciaObjectValue since they inherit from it.
Step 8: Tests
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 casestoString-tag.js # Symbol.toStringTagfrom.js, of.js # Static methods at top levelprototype/ # Instance methods — one file per methodmethodA.js # Happy paths + edge cases for methodAmethodB.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.
Step 9: Benchmarks
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);});
Step 10: Documentation
docs/built-ins.md
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 |
README.md
Add YourType to the built-in objects list.
Other documentation
- 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.
Checklist
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)
GC Considerations
- 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. MarkReferencesmust mark allTGocciaValuefields 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.
Common Pitfalls
- 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.