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.
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;
InitializeNativeFromArguments -- Called when new YourType(args) is used via the class constructor path:
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. - 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 loader)
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 loaderto 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.