Maps, Sets, Dates, BigInt
Overview
Although mobx-keystone keeps snapshots JSON-friendly, you can still work with Map, Set, Date, and bigint values. The recommended approaches are:
- Codecs (
types.codec(...)and built-in codecs) — reusable typed schemas that convert between a runtime value and its snapshot representation. Recommended for most use cases. - Collection wrappers (
asMap/asSet) — lightweight function wrappers that present aMap/Setinterface over plain arrays/objects.
Codecs
Codecs are typed schemas where the runtime value differs from the encoded snapshot value. They are part of the types.* system and integrate seamlessly with tProp(...), typeCheck(...), fromSnapshot(...), and getSnapshot(...).
Built-in codecs
These codecs are built in:
| Codec | Runtime type | Snapshot type |
|---|---|---|
types.bigint | bigint | string |
types.dateAsTimestamp | Date | number (timestamp) |
types.dateAsIsoString | Date | string (ISO 8601) |
types.mapFromObject(valueType) | Map<string, V> | Record<string, V> |
types.mapFromArray(keyType, valueType) | Map<K, V> | Array<[K, V]> |
types.setFromArray(valueType) | Set<V> | Array<V> |
Basic example
@model("MyApp/M")
class M extends Model({
id: tProp(types.bigint),
createdAt: tProp(types.dateAsTimestamp),
totals: tProp(types.mapFromObject(types.number), () => new Map()),
tags: tProp(types.setFromArray(types.string), () => new Set<string>()),
}) {}
const m = new M({
id: 1n,
createdAt: new Date(),
totals: new Map([["a", 1]]),
tags: new Set(["x"]),
})
m.id // bigint
m.createdAt // Date
m.totals // Map<string, number>
m.tags // Set<string>
getSnapshot(m) // { id: "1", createdAt: 1234567890, totals: { a: 1 }, tags: ["x"], ... }
Nested / composed codecs
Codecs compose naturally with each other and with other types.* combinators:
@model("MyApp/Schedule")
class Schedule extends Model({
// Map with codec-converted keys AND values
events: tProp(
types.mapFromArray(types.dateAsIsoString, types.bigint),
() => new Map()
),
// Optional bigint
limit: tProp(types.maybe(types.bigint)),
// Array of dates
holidays: tProp(types.array(types.dateAsTimestamp), () => []),
}) {}
const s = new Schedule({
events: new Map([[new Date("2024-01-01"), 42n]]),
limit: undefined,
holidays: [new Date("2024-12-25")],
})
s.events // Map<Date, bigint>
s.limit // bigint | undefined
s.holidays // Date[]
// Snapshot is fully JSON-serializable:
// { events: [["2024-01-01T00:00:00.000Z", "42"]], limit: undefined, holidays: [1735084800000], ... }
Custom codecs
You can create your own codecs with types.codec(...):
const urlType = types.codec({
typeName: "url",
encodedType: types.string,
is(value): value is URL {
return value instanceof URL
},
transform({ originalValue, cachedTransformedValue }) {
return cachedTransformedValue ?? new URL(originalValue)
},
untransform({ transformedValue, cacheTransformedValue }) {
cacheTransformedValue()
return transformedValue.toString()
},
})
// Use it like any other type:
@model("MyApp/Link")
class Link extends Model({
href: tProp(urlType),
altUrls: tProp(types.array(urlType), () => []),
}) {}
See the Runtime Type Checking page for the full types.codec(...) API reference.
There are also types.dateTimestamp (equivalent to types.integer) and types.dateString (equivalent to types.nonEmptyString), but these are plain type checkers with no runtime Date conversion. Prefer the codec versions types.dateAsTimestamp and types.dateAsIsoString for new code.
Collection wrappers
asMap and asSet are low-level utility functions that wrap plain observable objects/arrays into Map/Set-like interfaces. They can be useful when you need direct control over the underlying data. Unlike codecs, they do not convert values — they only provide a different interface over the same underlying data.
Note: Collection wrappers will return the same collection given the same backed object.
asMap collection wrapper
asMap will wrap either an object of type { [k: string]: V } or an array of type [string, V][] and wrap it into a Map<string, V> alike interface.
If the backed property is an object operations should be as fast as usual.
If the backed property is an array the following operations will be slower than usual:
setoperations will need to iterate the backed array until the item to update is found.deleteoperations will need to iterate the backed array until the item to be deleted is found.
class ... {
// given `myRecord: prop<{ [k: string]: V }>(() => ({}))`
get myMap() {
return asMap(this.myRecord)
}
// and if a setter is required
@modelAction
setMyMap(myMap: Map<string, V>) {
this.myRecord = mapToObject(myMap)
}
}
class ... {
// given `myArrayMap: prop<[string, V][]>(() => [])`
get myMap() {
return asMap(this.myArrayMap)
}
// and if a setter is required
@modelAction
setMyMap(myMap: Map<string, V>) {
this.myArrayMap = mapToArray(myMap)
}
}
// then `myMap` can be used as a standard `Map`
To convert it back to an object/array you can use mapToObject(map) or mapToArray(map). When the map is a collection wrapper it will return the backed object rather than do a conversion.
asSet collection wrapper
asSet will wrap a property of type V[] and wrap it into a Set<V> alike interface:
Note that, currently, since the backed property is actually an array the following operations will be slower than usual:
deleteoperations will need to iterate the backed array until it finds the value to be deleted.
class ... {
// given `myArraySet: prop<V[]>(() => [])`
get mySet() {
return asSet(this.myArraySet)
}
// and if a setter is required
@modelAction
setMySet(mySet: Set<V>) {
this.myArraySet = setToArray(mySet)
}
}
// then `mySet` can be used as a standard `Set`
To convert it back to an array you can use setToArray(set). When the map is a collection wrapper it will return the backed object rather than do a conversion.
Deprecated approaches
The following approaches are still functional but deprecated. For each one, the recommended alternative is listed.
Property transforms (.withTransform(...))
Alternative: Use tProp(types.codec(...)) or a built-in codec type. Wrap with types.skipCheck(...) if you don't need runtime validation.
Property transforms are applied per-property on top of a plain prop(...) declaration. They convert between the stored value and the value exposed on the model instance.
Compared to codecs, property transforms:
- Don't integrate with the type system (no
typeCheck(...), no typedfromSnapshot/getSnapshot) - Don't compose — each property declares its own transform
- Don't support nested conversion (e.g. a
Map<string, Date>) - Require you to manually declare snapshot types via
prop<StoredType>()
// ❌ Deprecated
@model("MyApp/M")
class M extends Model({
date: prop<number>().withTransform(timestampToDateTransform()).withSetter(),
}) {}
// ✅ Preferred
@model("MyApp/M")
class M extends Model({
date: tProp(types.dateAsTimestamp).withSetter(),
// or without runtime checking:
// date: tProp(types.skipCheck(types.dateAsTimestamp)).withSetter(),
}) {}
Creating a custom property transform
If you have a one-off conversion that doesn't warrant a full codec, you can still create a custom property transform:
// ModelPropTransform<TOriginalValue, TTransformedValue>
const _timestampToDateTransform: ModelPropTransform<number, Date> = {
transform({ originalValue, cachedTransformedValue, setOriginalValue }) {
return cachedTransformedValue ?? new ImmutableDate(originalValue)
},
untransform({ transformedValue, cacheTransformedValue }) {
if (transformedValue instanceof ImmutableDate) {
cacheTransformedValue()
}
return +transformedValue
},
}
export const timestampToDateTransform = () => _timestampToDateTransform
However, for reusable conversions, prefer types.codec(...) — see the Custom codecs section above.
Built-in transform helpers
All built-in transform helpers are deprecated. Use the corresponding codec instead:
| Deprecated transform | Codec alternative |
|---|---|
timestampToDateTransform() | types.dateAsTimestamp |
isoStringToDateTransform() | types.dateAsIsoString |
stringToBigIntTransform() | types.bigint |
objectToMapTransform() | types.mapFromObject(valueType) |
arrayToMapTransform() | types.mapFromArray(keyType, valueType) |
arrayToSetTransform() | types.setFromArray(valueType) |
Codecs additionally support nested conversion. For example, types.mapFromArray(types.dateAsIsoString, types.bigint) converts both keys and values, which is not possible with arrayToMapTransform().
If you want the codec conversion without runtime type-checking overhead, wrap with types.skipCheck(...):
tProp(types.skipCheck(types.dateAsTimestamp))
// equivalent to: prop<number>().withTransform(timestampToDateTransform())
// but with proper snapshot typing and composability
Collection models (ObjectMap / ArraySet)
Alternative: Use tProp(types.mapFromObject(...)), tProp(types.mapFromArray(...)), or tProp(types.setFromArray(...)).
ObjectMap and ArraySet are special model wrappers that provide Map-like and Set-like interfaces. They produce snapshots that include $modelType and $modelId metadata, whereas codecs produce clean plain objects/arrays.
ObjectMap collection model
// ❌ Deprecated
class ... extends Model({
myNumberMap: prop(() => objectMap<number>())
// or if there's no default value
myNumberMap: prop<ObjectMap<number>>()
}) {}
// ✅ Preferred
class ... extends Model({
myNumberMap: tProp(types.mapFromObject(types.number), () => new Map())
}) {}
Snapshot representation of ObjectMap (includes model metadata):
{
$modelType: "mobx-keystone/ObjectMap",
$modelId: "Td244...",
items: {
"key1": value1,
"key2": value2,
}
}
ArraySet collection model
// ❌ Deprecated
class ... extends Model({
myNumberSet: prop(() => arraySet<number>())
// or if there's no default value
myNumberSet: prop<ArraySet<number>>()
}) {}
// ✅ Preferred
class ... extends Model({
myNumberSet: tProp(types.setFromArray(types.number), () => new Set())
}) {}
Snapshot representation of ArraySet (includes model metadata):
{
$modelType: "mobx-keystone/ArraySet",
$modelId: "Td244...",
items: [
value1,
value2
]
}