immutable_model 0.9.4
immutable_model: ^0.9.4 copied to clipboard
Define valid, hierarchical immutable state models with an expressive API for updating state data and transitioning between states.
This package helps build valid, immutable state models in a succinct way (with no code generation) that are interoperable between commonly used state management systems and that support serialization to and from JSON out of the box.
Fully implemented examples using this package with the BLoC (and Cubits) and Redux frameworks can found in the example folder.
This package supports int's, double's, bool's, String's, DateTime's, lists, enums and maps, each having their own ModelType
representation (e.g. ModelInt
, ModelString
, ModelIntList
, ModelStringList
, ModelEnum<E>
etc.).
Maps between between Strings (field labels) and other ModelType
models are defined using the ModelInner
class. A ModelInner
is generally used for hierarchical nesting in a ImmutableModel
(hence "inner").
Every ModelType
can be passed a validator
which is run when during an update. If the update is invalid, the instance is returned without the update applied.
This package provides some commonly used ValueType
classes too (and a way to declare your own). A ValueType
is defined as a ModelType
that wraps it's own validator
. Currently only ModelEmail
and ModelPassword
for email and password Strings are provided, but more can be added.
Under the hood, the immutable list and map data structures are built using built_collection.
All ModelType
's extend Equatable for value equality based on the model's current value.
Finally, this package provides the main ImmutableModel<S>
class used to define state models. See the examples below.
Overview #
The main goal behind this package was to provide a way to write a provably safe domain layer, which is achieved in two ways:
First, state models defined using ImmutableModel
can never be in an invalid state (hold invalid data), if they are written correctly. This applies to all ModelType
's too.
If an attempt is made to update a state model with invalid data, the instance will be returned without the update applied. When an invalid update occurs an exception is logged instead of being throw, in keeping with the idea of never being in an invalid state. These logs can be accessed dynamically using the dart logging package.
Second, domain level logic or functions can use or be parameterized with the valid types from this library. The data these valid types hold must be valid or the instance cannot exist.
For example, a simple authentication state model could be written as follows:
final authStateModel = ImmutableModel(
{
"email": M.email(defaultEmail: 'example@gmail.com'),
"password": M.password(),
},
);
(note: M.email
and M.password
are shorthands for the ModelEmail
and ModelPassword
classes)
A domain level signIn
function could then be defined as:
signIn(ModelEmail email, ModelPassword password) {
if(authUser(email.asSerializable(), password.asSerializable())){
authStateModel.update({
"email": email,
"password": password
});
}
}
Where authUser
is a data layer function, with the following signature:
bool authUser(String email, String password)
By parameterizing the signIn
function with the ModelEmail
and ModelPassword
types, you can write the necessary logic without needing to worry about the validity of the data passed in. Code that calls the function (from the UI, for example) is forced to comply and instantiate the types before calling the function, which is only possible with valid data.
If instead the function was defined as signIn(String email, String password)
, each string would need to be validated inside the function and then react accordingly if they weren't. Even if you trust the values that are passed in, it is still possible that the values could be invalid.
This works with the data layer too. If the repository pattern is used, a ModelType
or an ImmutableModel
can be specified as the return type of the data functions, meaning the returned data would have to be valid.
This lets you write an authoritative domain layer. One that forces the correct implementation from the UI and data layers.
Using ImmutableModel
#
Note: this is not an exhaustive overview of the API, please see the API docs for that.
After defining a state model:
final aModelWithAnInner = ImmutableModel(
{
"some_int": M.nt(initialValue: 0),
"some_string": M.str(initialValue: "Hello"),
"the_inner": M.inner({
"another_string": M.str(initialValue: "World"),
}),
}
);
The values can be accessed as follows:
aModelWithAnInner["some_string"]; // "Hello"
aModelWithAnInner["the_inner"]["another_string"]; // "World"
(Note: normally when using the []
notation the model's .value
is returned, except when a ModelInner
is specified. Instead the ModelInner
instance will be returned, not it's .value
. The .get()
method can be used to always get the model .value
)
Updates use a map between field label Strings and values or functions:
final updatedModel = aModelWithAnInner.update({
"some_int": (n) => ++n,
"the_inner": {
"another_string": "New World".
}
});
updatedModel["some_int"]; // 1
updatedModel["the_inner"]["another_string"]; // New World
As shown in the above signIn
function, ModelType
models can also be used in the update map (see the Theory section for an explanation).
Using ModelSelector
#
A ModelSelector<V>
can be defined and used to update or access a ModelType
or its value in an ImmutableModel
(or ModelInner
).
A ModelSelector
is defined using a "selector string" which uses a '.' for accessing nested models.
V
is the value type of the model being selected.
The following selectors can be defined:
const someStringSel = ModelSelector<String>("some_string");
const anotherStringSel = ModelSelector<String>("the_inner.another_string");
And used to select values:
aModelWithAnInner.select(someStringSel); // "Hello"
aModelWithAnInner.select(anotherStringSel); // "World"
And to update values:
aModelWithAnInner.updateWithSelector(someStringSel, "New Hello");
aModelWithAnInner.updateWithSelector(anotherStringSel, "New World");
ImmutableModel
strict updates #
A strict update property can be enabled for a ImmutableModel
(or a ModelInner
) which will make any update to it require all fields defined in it.
For example, if the authStateModel
was defined as:
final authStateModel = ImmutableModel(
{
"email": M.email(defaultEmail: 'example@gmail.com'),
"password": M.password(),
},
strictUpdates: true,
);
Then calling:
authStateModel.update({
"email": email,
});
Would fail but calling:
authStateModel.update({
"email": email,
"password": password
});
Wouldn't.
This is useful when you need to model something more ephemeral like when you need to display information gotten from a REST API call and you require all the data specified by the model.
ImmutableModel
's can be reset #
The models held by ImmutableModel
can be reset by calling:
authStateModel.resetAll()
Which will reset all ModelType
instances held by it, or
authStateModel.resetFields([...]);
Which will reset all instances specified by the list of field labels.
In general, any ModelType
can be reset by accessing its initial
property.
ImmutableModel
's wrap a state #
An ImmutableModel<S>
is generic over some state S
, for example:
abstract class AuthState {
const AuthState();
}
class AuthInitial extends AuthState {
const AuthInitial();
}
class AuthSuccess extends AuthState {
const AuthSuccess();
}
final authStateModel = ImmutableModel<AuthState>(
{
"email": M.email(defaultEmail: 'example@gmail.com'),
"password": M.password(),
},
initialState: const AuthInitial(),
);
This can be used in the UI to check what state the model is in and react to changes in it by accessing the currentState
property, for example:
if(authStateModel.currentState is AuthSuccess){...}
States can be transitioned to by calling:
authStateModel.transitionTo(const AuthSuccess());
or
authStateModel.transitionToAndUpdate(const AuthSuccess(), {...});
which includes an update with a transition.
There's also:
authStateModel.updateIfIn({...}, const AuthSuccess());
Which will only update the state model if it's in the state specified.
Declaring your own ModelType
or ValueType
#
You can easily define your own ModelType
by extending the ModelValue
class.
The buildNext
method must be overridden which must call an internal constructor (normally called _next
) that takes a previous instance of the sub-class and must in turn call super.constructNext
.
If you want to create a value type which, as stated before, is a class that wraps its own validator, you must additionally use the ValueType
mixin.
For example:
class CityName extends ModelValue<CityName, String> with ValueType {
// checks if every word is capitalized
static final validator =
(String str) => (str).split(" ").every((w) => w[0] == w[0].toUpperCase());
static const label = "city_name";
CityName([String defaultCityName])
: super.text(defaultCityName.trim(), validator, label);
CityName._next(CityName previous, String value)
: super.constructNext(previous, value.trim());
@override
CityName buildNext(String nextValue) => CityName._next(this, nextValue);
}
This type can now be used in an ImmutableModel
and to parameterize functions.
See Value Types for more examples.
Theory #
It is helpful to understand a couple of concepts before using this library.
Every instance of a an ImmutableModel
(or any ModelType
used inside of it or otherwise) should be considered to be a node in a DAG. Every valid update of it leads to a progression in this graph, as a new instance is always returned
The first (or initial
) instance is important as it is used to define an Equality Of History between two models.
If a model "shares a history" with another, it implies that the models are equally valid, because every progression from the first instance must have been a valid one thus if they both come from the same initial instance, they must both be equally valid.
Even if two models are equal in terms of value, they may not share a history together.
If two model instances (A
and B
) do share a history, the one can be updated with the other, which will in effect just return the one used to update it. For example A.update(B)
will just return B
.
However, things are slightly different for value type Models
(such a ModelEmail
or ModelPassword
) because value types wrap their own validation (or they're supposed to), meaning any instance of some value type will be equally valid to any other instance of it, regardless of whether they share a history or not.
If you have two instances A
and B
of some value type and you call A.update(B)
a new instance C
will be returned with B
's value but with A
's initial
instance (in a similar vain to merging B
into A
).
The reason this is done is slightly esoteric but if you look at authStateModel
from above and the signIn
function, the ModelEmail
and ModelPassword
instances passed to the signIn
function do not share a history with the instances in the authStateModel
but, of course, are valid and thus can be used to update the values in the authStateModel
. If the models were simply replaced, one wouldn't be able to reset values correctly. For example, the defaultEmail
originally defined for the "email" field would be lost.
Supported Types #
These are all the ModelTypes
defined in this package:
Primitives:
ModelBool
ModelInt
ModelDouble
ModelString
ModelText
: a non-empty StringModelDateTime
Lists:
ModelBoolList
ModelIntList
ModelDoubleList
ModelStringList
ModelDateTimeList
Enums:
ModelEnum<E>
:E
is the enum type
Maps:
ModelInner
: Map between field label Strings and otherModelType
models.