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

Improve Typescript definition for Map #1841

Merged
merged 32 commits into from Aug 24, 2023

Conversation

jdeniau
Copy link
Member

@jdeniau jdeniau commented Jul 6, 2021

Imagine the following code:

const m = Map({ length: 3, 1: 'one' });

This was previously typed as Map<string, string | number>

and return type of m.get('length') or m.get('inexistant') was typed as string | number | undefined.

This made Map really unusable with TypeScript.

Now the Map is typed like this:

MapOf<{
    length: number;
    1: string;
}>

and the return type of m.get('length') is typed as number.

The return of m.get('inexistant') throw the TypeScript error:

Argument of type '"inexistant"' is not assignable to parameter of type '1 | "length"

If you want to keep the old definition

This is a minor BC for TS users, so if you want to keep the old definition, you can declare you Map like this:

const m = Map<string, string | number>({ length: 3, 1: 'one' });

If you need to type the Map with a larger definition

You might want to declare a wider definition, you can type your Map like this:

type MyMapType = {
  length: number;
  1: string | null;
  optionalProperty?: string;
};

const m = Map<MyMapType>({ length: 3, 1: 'one' });

Are all Map methods implemented ?

For now, only get, set and getIn methods are implemented. All other methods will fallback to the basic Map definition. Other method definition will be added later, but as some might be really complex, we prefer the progressive enhancement on the most used functions.

@jdeniau jdeniau marked this pull request as draft July 15, 2021 14:29
@mbullington
Copy link

Working today on seeing if getIn can be improved or implementing the other functions

@mbullington
Copy link

mbullington commented Jul 16, 2021

@jdeniau I spent a lot of time on getIn but finally(!) found a solution using a new TS feature called variadic tuple types: microsoft/TypeScript#39094

Mind if I have write access to your fork so I can push my changes?

@mbullington
Copy link

Happy to work more on this with you as well, if you have a good idea of what else needs implementing in order to review & merge.

@jdeniau
Copy link
Member Author

jdeniau commented Jul 17, 2021

@mbullington I gave you access on my fork. You can push on the jd-feat-tsMapObject branch.

If you want to continue on the subject, there are still some issues with constructor like Map(List(List(['a', 'A']))), but the worst thing for me is the ObjectLikeMap interface, which seems really weird.
The best solution for me would be to have a unique interface Map that handle both key/value sequence and also the object constructor, but I didn't handle that without breaking actual interface.
If we keep the two different interfaces, then all other method should be declared and are missing here (especially set and setIn), but can be declared later.

@mbullington
Copy link

mbullington commented Jul 19, 2021

Was able to push my changes.

Thinking about ObjectLikeMap, I think it's hard to integrate them because it's limited to string | number, while Map can have arbitrary key values.

What's the issue with Map(List(List(['a', 'A'])))? Haven't tested it but curious which constructor does it use?

@jdeniau
Copy link
Member Author

jdeniau commented Jul 20, 2021

Nice implementation ! One think I'm not sure about (and something I did added) is the GetMapType<S> that is not generic and could not be reused for Record, List, etc. as it is tied to the ObjectLikeMap, but your implementation is great : it does simplify a LOT the getIn function.

About Map(List(List(['a', 'A']))), IIRC, It should match Map(collection: Iterable<[K, V]>) but it doesn't as List(['a', 'A']) does not match the tuple [K, V] (as it's a List).

@jdeniau
Copy link
Member Author

jdeniau commented Nov 2, 2021

@bdurrer I will open a discussion or a wiki page once this is merged to keep the CHANGELOG light

@mbullington
Copy link

mbullington commented Nov 5, 2021

Thanks all and @jdeniau for working on this!

I can understand the argument that Map isn't the place for these and wanting to keep the typing aligned with the ES6 Map, so it's not without a good discussion.

I think the main goal of this is to enable strong typing for the "immutable POJOs," which in a lot of cases due to fromJS is how people use Immutable. @acusti There's no ES6 Map equivalent for that.

Right now it's hard to justify using the Immutable typing with fromJS since it'll sometimes get in the way, in the sense of "I know this is a string but I have to check because TS says it's a string | number" So this PR makes a world of difference there.

For people like @bdurrer 's use case I'd hate to flip this around, trading one alienated use case for another. If we can strike a balance (which I hope we've done?) both groups will be able to work productively.


I've been playing around with an internal version for Mapbox Studio that recursively types fromJS and toJS, as well as adding support for update.

I'd love some feedback on if these should be added or if the from/toJS logic is even correct:

https://gist.github.com/mbullington/631dd21f910e594b29fb97dd6f3e62e8

// https://github.com/immutable-js/immutable-js/issues/1462#issuecomment-584123268

/** @ignore */
type GetMapType<S> = S extends MapFromObject<infer T> ? T : S;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implies that getIn will be used on something that is MapFromObject all the way down. What happens when it includes a typical Map or List?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that it will just return the type Map or List, am I correct? @jdeniau

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On something that is not a MapFromObject, it will return the type directly. I do not really know in the end what will happen though. Maybe @mbullington who coded this part ?

I thing that:

MapFromObject<{ m: Map<string, number>; l: List<number>; }>
GetMapType<m['m']> // Map<string, number>
GetMapType<m['l']> // List<number>

Reading this particular line, the function only extract the initial object of a MapFromObject, that's all.

So in a chain:

MapFromObject<{
  m: Map<string, List<number>>
}>

Calling RetrievePath<R, ['m', 'some-string', number]> should return number

getIn<P extends ReadonlyArray<string | number | symbol>>(
searchKeyPath: [...P],
notSetValue?: unknown
): RetrievePath<R, P>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part of what has made typing getIn correctly challenging is that we don't necessarily know the nested types within. The recursive type you've defined here gets way closer, but if it's correct it needs to handle all possible cases that getIn operates on in the nested layers. Then there's no reason it can't be used in all getIn methods

get<NSV>(key: string, notSetValue: NSV): NSV;

// https://github.com/microsoft/TypeScript/pull/39094
getIn<P extends ReadonlyArray<string | number | symbol>>(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A nested structure may have keys that are not string | number | symbol

I think this needs to be ReadonlyArray<any> to account for all possible key types of a typical Map

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know why, but if I do that, calling getIn(['a', 'b', 'c', 'd']) is not handled and fallback to be a never type

notSetValue?: unknown
): RetrievePath<R, P>;

set<K extends keyof R>(key: K, value: R[K]): this;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to get above, I think this needs another definition which covers where key is not in keyof R with an appropriate return type

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if key is not in keyof R immutable will return a undefined right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EduardoAraujoB if key is not in keyof R, then technically a new Map is returned:

Map({}).set('a', 'a'); // output Map({ a: 'a' })

@leebyron As said on slack I prefered the solution of replicating TypeScript objects: you need to define the interface before:

const o = { a: 'a' };
o.b = 'b'; // Property 'b' does not exist on type '{ a: string; }'.

* that does not guarantee the key was not found.
*/
get<K extends keyof R>(key: K, notSetValue?: unknown): R[K];
get<NSV>(key: string, notSetValue: NSV): NSV;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be key: any here? K alone can be string | number | symbol, but beyond I think we would expect that for any requested key that doesn't exist, we ask a NSV to be provided

Comment on lines +63 to 68
// @ts-expect-error -- Not supported by typescript since 4.0.0 https://github.com/immutable-js/immutable-js/pull/1626
expect(m.get('a')).toBe('A');
// @ts-expect-error -- Not supported by typescript since 4.0.0 https://github.com/immutable-js/immutable-js/pull/1626
expect(m.get('b')).toBe('B');
// @ts-expect-error -- Not supported by typescript since 4.0.0 https://github.com/immutable-js/immutable-js/pull/1626
expect(m.get('c')).toBe('C');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused - is this not currently failing? Why does this PR cause this to start producing errors?

Comment on lines 86 to 87
// $ExpectError
Map({ a: 4 }).get('b');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add .get('b', undefined) to test the alternate NSV definition?

type-definitions/ts-tests/map.ts Show resolved Hide resolved
Map({ a: 4, b: true }).getIn(['a']);

// $ExpectType number
Map({ a: Map({ b: Map({ c: Map({ d: 4 }) }) }) }).getIn(['a', 'b', 'c', 'd']);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add getIn tests where nested Map are constructed by other means (eg not only ObjectFromMap type?) as well as having List within?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunatly, it's only working for MapFromObject for now.

@@ -80,6 +115,28 @@ import { Map, List } from 'immutable';

// $ExpectType Map<number, string | number>
Map<number, number | string>().set(0, 'a');

// $ExpectError
Map({ a: 1 }).set('b', 'b');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised by this, I'd expect either MapFromObject<{ a: 1, b: 'b' }> or Map<string, number | string> as a result?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment above about the consistency with plain objects in TS

@mbullington
Copy link

mbullington commented Dec 1, 2021

I took a stab at making sure merge & co work properly.


@leebyron Thanks for looking and identifying these issues with getIn.

In honesty I changing never to unknown would be best here, and keeping the current object-all-the-way-down path as helpful but not stopping you from using getIn otherwise.

My reasoning is we're dealing with both string literal types like "car" and then generic types like string. With the [...T] syntax (microsoft/TypeScript#39094) I had to make it ReadonlyArray<string | number | symbol> to get the path type looking like ["a", "b", "c"] using literals. Otherwise it would use [string, string, string], so when we check if string extends "a" | "b" | "c", it doesn't and returns never.

This could present issues with typing getIn for non-object-all-the-way-down structures.


I wonder if there's a better way of typing getIn, I wonder if there's TS office hours or someone we could ask given Immutable's popularity as a project.

@eesaber
Copy link

eesaber commented Apr 17, 2022

Hi @mbullington

You can get the type of the given key path of a plain JS object like

type Arr = readonly unknown[];

type GetInType<T, KeyPath extends Arr> =
  KeyPath extends [infer U]
    ? U extends keyof T 
      ? T[U]
      : 'fail 1'
    : KeyPath extends [infer K, ...infer Rest]
      ? K extends keyof T 
        ? GetInType<T[K], Rest>
        : 'fail 2'
      : 'fail 3'

// ---
const obj = {
  foo: {
    list: [1,2,3],
    listObj: [{name: "alex"},{name: "sam"}],
  },
  num: 123,
};

type test = GetInType<typeof obj, ['foo', 'list']>;

So, the type of test is number[]

Playground Link

For ImmutableJS, I guess we could replace T[U] by something like T.get(U).

@jbaca303
Copy link

What's the status on this? Looks like it would be highly beneficial for a project my team is working on

@jdeniau
Copy link
Member Author

jdeniau commented Mar 14, 2023

@jbaca303 it's a work in progress but it's really complex to make this work properly.

Don't expect it to be done in the next few weeks, but if you would like to try it on your project, every return would be appreciable.

@jbaca303
Copy link

@jbaca303 it's a work in progress but it's really complex to make this work properly.

Don't expect it to be done in the next few weeks, but if you would like to try it on your project, every return would be appreciable.

Thanks for the quick update. Totally understand that this complex. Thank you guys for working on this, really appreciate it!

@jdeniau jdeniau changed the base branch from main to 5.x August 24, 2023 09:56
@jdeniau jdeniau added this to the 5.0 milestone Aug 24, 2023
@jdeniau
Copy link
Member Author

jdeniau commented Aug 24, 2023

I am using this for nearly 2 years now at https://github.com/mapado internally.

I think it is time to go public now. I will release a RC on a 5.0 version to have some community return on this.

Thanks you all for your patience.

@jdeniau jdeniau merged commit c25fac2 into immutable-js:5.x Aug 24, 2023
5 checks passed
@jdeniau jdeniau mentioned this pull request Aug 24, 2023
This was referenced Jan 29, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Better Type Inference
9 participants