mobxjs / mobx-state-tree

Full-featured reactive state management without the boilerplate
https://mobx-state-tree.js.org/
MIT License
6.93k stars 641 forks source link

RFC: Introducing action, computed and view types atoms #893

Open mattiamanzati opened 6 years ago

mattiamanzati commented 6 years ago

Preface: This PR contains a lot of breaking things I am looking for feedback, please reply if they make sense or not for your needs. Not sure if they all make sense for a 3.0 release, but I think they would for a next major version like 4.0

Changing from the IType<S, T> to IType<A, O = A, I = O, F = A>

Right now the IType interface accepts two parameters; the runtime type and the snapshot one.

If you think about it, that's not completely true. We need more types to represent an MST type. Here's what they should be:

Introducing action, computed and view types

One of the recurring isses in MST usage with TS is the typing of "self" when defining actions / views.

const Example = types
    .model({
        prop: types.string
    })
    .views(_self => {
        const self = _self as IExample;
        return {
            get upperProp(): string {
                return self.prop.toUpperCase();
            },
            get twiceUpperProp(): string {
                return self.upperProp + self.upperProp;
            }
        };

    });

type ExampleType = typeof Example.Type;
interface IExample extends ExampleType {}

At the moment MST requires that they should be declared in a predefined order to make them correctly work. There are also issues when views/action may call each other recursively.

A really nice way to solve this is to include action and view types in props definition, and later require them to have an actual implementation.

const Example = types.object({
  prop: types.string,
  upperProp: types.computed(types.string),
  twiceUpperProp: types.computed(types.string)
}).finalize(self => ({
  upperProp: () => self.prop.toUpperCase(),
  twiceUpperProp: () => self.upperProp + self.upperProp
}))

As you can see now actions can depend on each other, and be used in code even if not declared yet. But how should I declare the computed/view/action declaration now? That's what finalizer do!

Finalizers are functions that are called upon instance contstruction and may return data to improve/finalized the instance based on how the type is declared. For example, the object type P accepts as finalizer a Partial<P>, and the object type will squash that partial into the object instance once created (exaclty how happens with action and views). In the same way, the computed type (of string) accepts as finalizer value a function returning a value (of string). The action/view/computed has an Input and Output type of never that means that cannot be restored from snapshot, and the object type will automatically remove those properties, as valuewise it is not possible to have a property with "never" as value.

Below at the end of the issue there is a working typelevel implementation of the new types.

Once https://github.com/Microsoft/TypeScript/pull/24897#issue-194165511 will be merged in next major, we will be able to convert the type of the argument of the below example from a tuple to a function with that exact tuple as input args.

Lazily wrap actions

Since actions are now indipendent atoms, we can lazily wrap them with MST internals, instead of doing that upon instance creation. This will increase performance as done in #810 / #845 because actions will be wrapped only upon first usage.

Back to structural checking, as TS does

Having atomic action with typed parameters, means we can perform checks also on their result and parameter types. At the moment each types.model call will produce a new "primitive". With the action change, we can introduce structural comparison even on actions, so on models too. This will be more inline with TS, because before TS was ok on assigning different MST types if they have the same signature (bug).

const Point = types.model({
    x: types.number,
    y: types.number,
    setXY: types.action(types.void, [types.number, types.number])
})
type IPoint = TypeOf<typeof Point>

const Coord = types.model({
    x: types.number,
    y: types.number,
    setXY: types.action(types.void, [types.number, types.number])
})
type ICoord = TypeOf<typeof Coord>

const coord: ICoord = Point.create({x: 1, y: 2}) // In 2.1 TS is ok with that
const thisIsFalse: false = Coord.is(coord) // this is false, because coord is instance of Point and not Coord (so the TS behaviour is not the MST one! D:)

after this change, Point and Coord will be compared structurally, so as long the types are assignable between them, MST (as TS does) will be ok with assigning them.

Faster computeds

As someone pointed out (not really a big problem :P), computed properties are slower to create, with this change the computed implementation is not copied anymore from a getter, but instead already provided as a function. So basically there's 1 computed less the current implementation.

Introduce intersection (a.k.a typewide compose)

Thanks to these new additions, we could also introduce a type called intersection. Intersection is represented at TS level with the & operator, and basically means that a type will have both the features of two given types. Behind the scenes, it will call create on all types and Object.assign features of the subsequent to the first one. Then all finalizers will be called with "self" being the just constructed object. This will allow also intersection between ES6 maps and objects.

For example:

const AdvancedMap = types.intersection(
    types.map(types.string),
    types.object({
        toString: types.view(types.string)
    })
).finalize(self => ({
    toString: () => Array.from(self.keys()).join(", ")
}))

Full working typelevel code

type IType<A, O, I, F> = {
  "-A": A;
  "-O": O;
  "-I": I;
  "-F": F;
  finalize<NF extends F>(fn: (self: A) => F): IType<NF & A, O, I, never>
};

type AnyType = IType<any, any, any, any>;
type TypeOf<T extends AnyType> = T["-A"];
type InputOf<T extends AnyType> = T["-I"];
type OutputOf<T extends AnyType> = T["-O"];
type FinalizerOf<T extends AnyType> = T["-F"];

type AnyTupleOf<T> = Array<T> & { "0": T };
type TypeTupleToTypeTuple<P extends Array<AnyType>> = Pick<{
  [K in keyof P]: P[K] extends AnyType ? TypeOf<P[K]> : P[K]
}, Exclude<keyof P, keyof Array<any>>> & {length: P["length"]};

type Action<R extends AnyType, P extends AnyTupleOf<AnyType>> = IType<
  (args: TypeTupleToTypeTuple<P>) => TypeOf<R>,
  never,
  never,
  (args: TypeTupleToTypeTuple<P>) => TypeOf<R>
>;

type Computed<R extends AnyType> = IType

type Clean<P> = Pick<P, ({[K in keyof P]: P[K] extends never ? never : K})[keyof P]>

declare const types: {
  void: IType<void, void, void, void>;
  string: IType<string, string, string, string>;
  number: IType<number, number, number, number>;
  object: <P extends { [K: string]: AnyType }>(
    props: P
  ) => IType<
    Clean<{ [K in keyof P]: TypeOf<P[K]> }>,
    Clean<{ [K in keyof P]: OutputOf<P[K]> }>,
    Clean<{ [K in keyof P]: InputOf<P[K]> }>,
    Clean<{ [K in keyof P]?: FinalizerOf<P[K]> }>
  >;
  action: <R extends AnyType, P extends AnyTupleOf<AnyType>>(
    returns: R,
    params: P
  ) => Action<R, P>;
  computed: <R extends AnyType>(returns: R) => IType<TypeOf<R>, OutputOf<R>, InputOf<R>, () => TypeOf<R>>
};

const User = types.object({
  id: types.number,
  name: types.string,
  setName: types.action(types.void, [types.string])
}).finalize(self => ({
  /**
   * Hello!
   */
  setName(params){
    self.name = params[0] // once Microsoft/typescript#24897 will be in (shortly), we can remove this array requirement.
  }
}))

const Example = types.object({
  prop: types.string,
  upperProp: types.computed(types.string),
  twiceUpperProp: types.computed(types.string)
}).finalize(self => ({
  upperProp: () => self.prop.toUpperCase(),
  twiceUpperProp: () => self.upperProp + self.upperProp
}))

type IUser = TypeOf<typeof User>
type IUserSnapshot = InputOf<typeof User>

declare const john: IUser
john.setName(["lol"]) // compiles, OK! :D
john.setName([1]) // error, OK! :D
john.setName(["trollol", "anotherLol"]) // error, OK! :D
john.setName() // error, OK! :D
mweststrate commented 6 years ago

@mattiamanzati interesting ideas! Did you see #774 btw :) it already separates construction snapshot type and normal snapshot type

k-g-a commented 6 years ago

@mattiamanzati sounds nice. I have two questions: 1) Will those type declarations on model be optional to use? The thing I'm afraid of is that amount of code to be written by end-users will increase. Moreover describing same thing twice (as type & as implementation) in different places makes changes/refactoring harder (e.g. renaming a computed, or changing signature of an action). 2) Does finalize mean to substitute actions/views/volatile/extend?

mattiamanzati commented 6 years ago

@k-g-a heres some tips:

Will those type declarations on model be optional to use?

They will be mandatory :)

The thing I'm afraid of is that amount of code to be written by end-users will increase. Moreover describing same thing twice (as type & as implementation) in different places makes changes/refactoring harder (e.g. renaming a computed, or changing signature of an action).

If you think about it, for people using TS this is a win, because you won't need type annotations in the finalizer, as the types will be inferred from the declaration.

Does finalize mean to substitute actions/views/volatile/extend?

actios, views, volatile, etc.. can be aliased to the new syntax and converted back using the new syntax and declaring action as accepting any parameter. I would slowly introduce this change.

...extend?

model.extend will became just an alias for intersection :)

mweststrate commented 6 years ago

The more I look at it, the better I like it, it solves quite some nasty patterns. Refactoring (not just the MST code base, but all projects that use it in general) will be huge though. Would it it be possible to introduce this as experimental feature, with an api like types.model2(...) than produces an IType<C, S, T> so that it would be an alternative syntax instead of a replacement and phase the old one over time if this works better indeed? (For example by using a code mod?)

mattiamanzati commented 6 years ago

Ideally the old API can be easily polyfilled to be mapped to the new one; also this should be done in a major release, and as we did from 0.9 to 0.10 I can provide a codemod too :)

mattiamanzati commented 6 years ago

We can call the new type "object" or "interface" as it is an interface that contains methods :)

pvpshoot commented 5 years ago

any updates here?

mario-jerkovic commented 4 years ago

any update?