Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Typescript) frozen fields without a default always include undefined as a possible type #932

Closed
6 tasks done
fruitraccoon opened this issue Jul 19, 2018 · 7 comments
Closed
6 tasks done

Comments

@fruitraccoon
Copy link
Contributor

fruitraccoon commented Jul 19, 2018

I have:

  • I think something is not working as it should.
    • I've checked documentation and searched for existing issues
    • I've made sure your project is based on the latest MST version
    • Fork this code sandbox or another minimal reproduction.
    • Describe expected behavior
    • Describe observed behavior

Expected Behaviour:
For a model like this:

const MyModel = types.model('MyModel', {
  myField: types.array(types.frozen<MyInterface>())
});

I would expect the type of myField to effectively be Array<MyFieldType>.

Observed Behaviour:
The type of myField is effectively Array<MyFieldType | undefined>.

I expected to have to use something like myField: types.array(types.frozen<MyInterface | undefined>()) or wrap in a maybe/optional to get this.

Detail
I'm looking to update from version 2.x to version 3 to take advantage of improvements, and have found this a little inconsistent:

  • For MST model fields defined using other MST models, the field must be wrapped in a maybe/optional in order for undefined to be a valid value of the field.
  • For the new typed frozen fields, the defaulting functionality appears to be already included (as an optional constructor) rather than using maybe/optional.

This means that when wanting a field defined as an array of frozen objects, one of the following trade-offs must be used:

  • declare it like field: types.array(types.frozen<MyItemType>()), and then always have to check if individual items are undefined (and pushing undefined is allowed)
  • declare it like field: types.array(types.frozen<MyItemType>(myDefaultItemInstance)), which has the correct typing, but the default instance is never used and can be difficult to come up with for complicated types (think large hierarchical business entities read from a server).

Questions:

  • Are there any other approaches that I haven't thought of to get the typing I'm after?
  • If not, is there an additional overload that could be added (or an additional type) to use to be able to explicitly define the frozen type used? (Looking for guidance on the preferred approach in this case - I could have a go at a PR if it's not too out-there!)

Example codesandbox available here: https://codesandbox.io/s/5xj57x0ym4 - please see src/models/Frozen.ts and the compareFrozenFields action.

Thanks very much!

Edit:
I've also found that when a frozen field should be optionally undefined, I still have to use types.maybe otherwise when I try to use that type, typescript insists that the field must be present.

For example, the following will show an error for the value passed to types.optional (it wants { myField: undefined }):

const ModelA = types.model('ModelA', { myField: types.frozen<MyType>() });
const ModelB = types.model('ModelB', { modelA: types.optional(ModelA, {}) });

But this is fine:

const ModelA = types.model('ModelA', { myField: types.maybe(types.frozen<MyType>()) });
const ModelB = types.model('ModelB', { modelA: types.optional(ModelA, {}) });

Even though the type of ModelA.myField is the same in both cases (MyType | undefined). This may be the way it's supposed to work, but I thought it worth mentioning.

@fruitraccoon
Copy link
Contributor Author

I've worked around this by creating a helper function:

export function fakeDefault<T>() {
  return (undefined as any) as T;
}

and then using it when creating arrays of frozen:

myField: types.array(types.frozen(fakeDefault<MyFieldType>()))

The typings for v3 are great btw - model definition is simpler and the stronger typings meant I was able to catch quite a few subtle bugs that has slipped through previously! 🙏

@mweststrate
Copy link
Member

Confirmed, both cases are fixed by #937. Great work @xaviergonz! Will release later

mweststrate added a commit that referenced this issue Jul 26, 2018
@mweststrate
Copy link
Member

Released as 3.0.2

@Ghirigoro
Copy link

Ghirigoro commented Jan 31, 2019

I'm not sure if this is exactly related to this but I'm experiencing a similar issue using mst 3.10.0 with TS 3.2.2. When I access a frozen type that has array members I get types that are possibly undefined, whcich seems wrong (at least to me). For example:

const ItemModel = types.model("Item", {
    value: 42
});

const InventoryModel = types.model("Inventory", {
    items: types.array(ItemModel)
});

const PersonModel = types.model("Person", {
    inventory: types.frozen(InventoryModel)
});

const inventory = InventoryModel.create();
const person = PersonModel.create({ inventory: getSnapshot(inventory) });

// Why is `personItems` potentially undefined but not`inventoryItems`?
const inventoryItems = inventory.items;
const personItems = person.inventory.items;

Does this mean that this issue is not resolved or am I making some sort of conceptual error?

@xaviergonz
Copy link
Contributor

Because undefined is a valid value to create an array type, since arrays are optional by default.

SnapshotIn<typeof InventoryModel> = {value: number}[] | undefined // frozen uses this one
SnapshotOut<typeof InventoryModel> = {value: number}[]

and therefore undefined would be a valid "array" when the runtime type checks it

e.g.

test("someTest", () => {
    const ItemModel = types.model("Item", {
        value: 42
    })

    const InventoryModel = types.model("Inventory", {
        items: types.array(ItemModel)
    })

    const PersonModel = types.model("Person", {
        inventory: types.frozen(InventoryModel)
    })

    // this does not fail in runtime
    const person = PersonModel.create({ inventory: { items: undefined } })
    expect(person.inventory.items).toBeUndefined()
})

@Ghirigoro
Copy link

@xaviergonz: Thanks for clearing that up. I guess I'm confused about what optional means. My understanding is that it's optional in the sense that you don't have to provide a value for such a field when initializing a model since a default will be provided. The way you're describing it corresponds more to what I thought maybe meant (i.e. there's no guarantee that this field was initialized).

P.S. - Sorry for all the questions, but while you're here I would love to understand what the difference is between in and out types for snapshots. Why isn't the type serialized to not identical to the type serialized from?

@xaviergonz
Copy link
Contributor

xaviergonz commented Jan 31, 2019

In an optional value the input json value might be undefined, but the output json will always contain a value, since the input will be transformed

I usually think about it like this
const optional=(in: val | undefined, default) => val !== undefined ? val : default

Therefore the input data (snapshot in) can be undefined, but the instance and the output data (snapshot out) won't

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants