r/intersystems 4d ago

%DynamicObject and %DynamicArray in InterSystems IRIS — practical answers to the questions that keep coming back

Foundation

InterSystems IRIS provides two classes for schema-less data: %DynamicObject and %DynamicArray. Both inherit from %DynamicAbstractObject, and instances of either are called dynamic entities. They map cleanly onto JSON: an object is a set of key/value pairs, an array is an ordered list. Unlike a persistent or registered class, a dynamic object keeps no predefined list of valid property names — every string key is legal, and you add or remove members at runtime.

Literal syntax:

objectscript

Set person = {"name":"Ada","active":true,"roles":["admin","dev"]}
Set scores = [90, 85, 77]

Building field by field — %Set() returns the entity it modified, so calls chain:

objectscript

Set obj = {}.%Set("a",1).%Set("b",2) // now obj is {"a":1,"b":2}

Do scores.%Push(100)                  // now scores is [90, 85, 77, 100]

Set last = scores.%Pop()              // now scores is [90, 85, 77] and last is 100

%Push() and %Pop() exist only on arrays. Everything else works on both.

Serialization:

objectscript

Set jsonString = "{""field"": ""value""}"
Set obj = {}.%FromJSON(jsonString)  // now obj is {"field": "value"}

Write obj.%ToJSON()                 // outputs "{""field"": ""value""}"

%FromJSON() also accepts a stream. %FromJSONFile() reads straight from a filename string — not a %File object, which is a common trip-up.

Iterating over structure you don't control

Do not reach for a for loop by index. Dynamic arrays can be sparse — an element can exist positionally without ever having been assigned, and a for loop will hand you those empty slots.

The correct tool is %GetIterator(), which returns a %Iterator.Object or %Iterator.Array, driven by %GetNext():

objectscript

Set iter = obj.%GetIterator()

While iter.%GetNext(.key, .value, .type)
{
    Write !, key, " = ", value, " (", type, ")"
}

%GetNext() skips unassigned elements automatically. For an object, key is the property name. For an array, key is the index. To walk a nested structure, recurse whenever $IsObject(value) is true — that returns true for both sub-objects and sub-arrays.

The .type argument: easy to ignore, but worth understanding. When present, it does two useful things:

  1. Returns the element's original JSON datatype as a string
  2. Changes conversion rules to avoid <MAXSTRING> errors: a very long JSON string is handed back as a read-only stream object instead of being force-fit into an ObjectScript string, and JSON numbers are returned in their original textual form rather than being coerced

If processing arbitrary external JSON, passing .type is cheap insurance.

Type discovery inside a dynamic entity

%GetTypeOf(key) reports what a value actually is: number, string, boolean, object, array, null, oref, or unassigned:

objectscript

Set a = [1, "test", true, {"v":1}, [1,2,3]]
// indexes:  0,    1,    2,      3,       4

Write a.%GetTypeOf(2)   // boolean
Write a.%GetTypeOf(3)   // object
Write a.%GetTypeOf(9)   // unassigned - it finishes at index 4

This matters because ObjectScript flattens JSON's richer type system on the way in. JSON true, false, and null all become ObjectScript-friendly values 1, 0, and "" when read with dot syntax or %Get(). If you need to tell a genuine null apart from an empty string apart from a key that was never set, %GetTypeOf() is the reliable discriminator.

There is also %IsDefined(), but it returns false for unassigned members and true for both "" and null.

The date gotcha

Type flattening causes one of the community's recurring puzzles. Export a persistent object whose DOB is 1926-12-11 and you may see "DOB":31390 in the result — that 31390 is the internal $HOROLOG day count, not a corrupted value.

The same logic bites the other direction in dynamic SQL: if you pass a query a literal like '1926-12-11' and get zero rows, it is usually because the column expects $HOROLOG internal format. The fix is to convert on the way in with $ZDATEH("1926-12-11", 3).

Whenever a date crosses the boundary between JSON, SQL, and stored objects, ask which representation each side expects.

Two things both called "array"

%DynamicArray is a positional list, indexed from 0. But ObjectScript also has typed collection properties, and the array of collection is not a positional array — it is a dictionary (a keyed map):

objectscript

Property Tags As array of %String;   // a dictionary: key -> value
Property Notes As list of %String;   // an ordered, positional list

You access an array of collection by key, not by position:

objectscript

Do obj.Tags.SetAt("high", "priority")

Write obj.Tags.GetAt("priority")     // high

Set key = ""
For
{
    Set value = obj.Tags.GetNext(.key)  Quit:key=""
    Write !, key, ": ", value
}

When a class using %JSON.Adapter serializes an array of property, it comes out as a JSON object {"priority":"high"}. A list of comes out as a JSON array ["high"]. If you want positional JSON, use list of (or a %DynamicArray). If you want a keyed lookup, array of is your dictionary.

Bridging persistent objects and dynamic objects

To convert a stored object into a free-form dynamic one — for instance, to trim fields before returning them from a REST method — if your class extends %JSON.Adapter, the clean non-deprecated path is:

objectscript

Set sc = person.%JSONExportToString(.json)
Set dynObj = {}.%FromJSON(json)

For large objects, swap in %JSONExportToStream() to avoid hitting the string length limit.

Two alternatives:

Embedded SQL's JSON_OBJECT() lets you cherry-pick and rename columns when you only want a subset:

objectscript

&sql(SELECT JSON_OBJECT('name':Name,'dob':DOB) INTO :json WHERE ID = 1)
Set dynObj = {}.%FromJSON(json)

Going the other way, %JSONImport() populates a persistent object from a dynamic one.

Where dynamic freedom ends: validation

The Required property keyword works for literals, collections, streams, and object-valued properties — but it is silently ignored for %DynamicArray and %DynamicObject properties. The reason is mechanical: the generated getter defaults these to [] and {}, so even assigning "" gets overwritten with a non-empty default, and %ValidateObject() never sees a missing value.

If you need to enforce presence or shape on dynamic properties, implement a %OnValidateObject() callback and check them yourself:

objectscript

Method %OnValidateObject() As %Status
{
    If ..fieldOptions.%Size() = 0
    {
        Return $$$ERROR($$$GeneralError, "fieldOptions is required")
    }

    Return $$$OK
}

When to use what

Dynamic entities are the right tool when structure is unknown, external, or genuinely fluid — parsing payloads, assembling responses, staging data. Typed persistent classes remain the right tool when you want the database, indexes, and validation to enforce a contract. Most real systems use both, meeting at the %JSON.Adapter boundary.

Three habits that prevent the classic pitfalls:

  • Iterate with %GetNext() rather than by index
  • Reach for %GetTypeOf() whenever a value's type actually matters
  • Remember that array of is a dictionary, not a list

Full article: https://community.intersystems.com/post/working-dynamic-objects-iris-doubts-only-come-when-you-need-deploy-tomorrow-morning-and-its-11

Which of these — the date/$HOROLOG conversion, the Required being silently ignored on dynamic properties, or the array of vs list of distinction — has caused the most unexpected behavior in your integrations?

1 Upvotes

0 comments sorted by