Skip to content

Better getIn TypeScript RetrievePath #2070

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

Merged
merged 3 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 48 additions & 18 deletions type-definitions/immutable.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ declare namespace Immutable {
*/
export type Comparator<T> = (left: T, right: T) => PairSorting | number;

/**
* @ignore
*
* KeyPath allowed for `xxxIn` methods
*/
export type KeyPath<K> = OrderedCollection<K> | ArrayLike<K>;

/**
* Lists are ordered indexed dense collections, much like a JavaScript
* Array.
Expand Down Expand Up @@ -873,7 +880,7 @@ declare namespace Immutable {
// TODO `<const P extends ...>` can be used after dropping support for TypeScript 4.x
// reference: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#const-type-parameters
// after this change, `as const` assertions can be remove from the type tests
getIn<P extends ReadonlyArray<string | number | symbol>>(
getIn<P extends ReadonlyArray<PropertyKey>>(
searchKeyPath: [...P],
notSetValue?: unknown
): RetrievePath<R, P>;
Expand Down Expand Up @@ -905,8 +912,14 @@ declare namespace Immutable {
// Loosely based off of this work.
// https://github.com/immutable-js/immutable-js/issues/1462#issuecomment-584123268

/** @ignore */
type GetMapType<S> = S extends MapOf<infer T> ? T : S;
/**
* @ignore
* Convert an immutable type to the equivalent plain TS type
* - MapOf -> object
* - List -> Array
*/
type GetNativeType<S> =
S extends MapOf<infer T> ? T : S extends List<infer I> ? Array<I> : S;

/** @ignore */
type Head<T extends ReadonlyArray<unknown>> = T extends [
Expand All @@ -915,28 +928,32 @@ declare namespace Immutable {
]
? H
: never;

/** @ignore */
type Tail<T extends ReadonlyArray<unknown>> = T extends [unknown, ...infer I]
? I
: Array<never>;

/** @ignore */
type RetrievePathReducer<
T,
C,
L extends ReadonlyArray<unknown>,
> = C extends keyof GetMapType<T>
? L extends []
? GetMapType<T>[C]
: RetrievePathReducer<GetMapType<T>[C], Head<L>, Tail<L>>
: never;
NT = GetNativeType<T>,
> =
// we can not retrieve a path from a primitive type
T extends string | number | boolean | null | undefined
? never
: C extends keyof NT
? L extends [] // L extends [] means we are at the end of the path, lets return the current type
? NT[C]
: // we are not at the end of the path, lets continue with the next key
RetrievePathReducer<NT[C], Head<L>, Tail<L>>
: // C is not a "key" of NT, so the path is invalid
never;

/** @ignore */
type RetrievePath<
R,
P extends ReadonlyArray<string | number | symbol>,
> = P extends [] ? P : RetrievePathReducer<R, Head<P>, Tail<P>>;
type RetrievePath<R, P extends ReadonlyArray<PropertyKey>> = P extends []
? P
: RetrievePathReducer<R, Head<P>, Tail<P>>;

interface Map<K, V> extends Collection.Keyed<K, V> {
/**
Expand Down Expand Up @@ -5908,6 +5925,9 @@ declare namespace Immutable {
updater: (value: V | NSV) => V
): { [key: string]: V };

// TODO `<const P extends ...>` can be used after dropping support for TypeScript 4.x
// reference: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#const-type-parameters
// after this change, `as const` assertions can be remove from the type tests
/**
* Returns the value at the provided key path starting at the provided
* collection, or notSetValue if the key path is not defined.
Expand All @@ -5922,10 +5942,20 @@ declare namespace Immutable {
* getIn({ x: { y: { z: 123 }}}, ['x', 'q', 'p'], 'ifNotSet') // 'ifNotSet'
* ```
*/
function getIn(
collection: unknown,
keyPath: Iterable<unknown>,
notSetValue?: unknown
function getIn<C, P extends ReadonlyArray<PropertyKey>>(
object: C,
keyPath: [...P]
): RetrievePath<C, P>;
function getIn<C, P extends KeyPath<unknown>>(object: C, keyPath: P): unknown;
function getIn<C, P extends ReadonlyArray<PropertyKey>, NSV>(
collection: C,
keyPath: [...P],
notSetValue: NSV
): RetrievePath<C, P> extends never ? NSV : RetrievePath<C, P>;
function getIn<C, P extends KeyPath<unknown>, NSV>(
object: C,
keyPath: P,
notSetValue: NSV
): unknown;

/**
Expand Down
69 changes: 68 additions & 1 deletion type-definitions/ts-tests/functional.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { expect, test } from 'tstyche';
import { get, has, set, remove, update } from 'immutable';
import {
get,
getIn,
has,
set,
remove,
update,
Map,
List,
MapOf,
} from 'immutable';

test('get', () => {
expect(get([1, 2, 3], 0)).type.toBe<number | undefined>();
Expand All @@ -11,6 +21,63 @@ test('get', () => {
expect(get({ x: 10, y: 20 }, 'z', 'missing')).type.toBe<number | 'missing'>();
});

test('getIn', () => {
expect(getIn('a', ['length' as const])).type.toBe<never>();

expect(getIn([1, 2, 3], [0])).type.toBe<number>();

// first parameter type is Array<number> so we can not detect that the number will be invalid
expect(getIn([1, 2, 3], [99])).type.toBe<number>();

// We do not handle List in getIn TS type yet (hard to convert to a tuple)
expect(getIn([1, 2, 3], List([0]))).type.toBe<unknown>();

expect(getIn([1, 2, 3], [0], 'a' as const)).type.toBe<number>();

expect(getIn(List([1, 2, 3]), [0])).type.toBe<number>();

// first parameter type is Array<number> so we can not detect that the number will be invalid
expect(getIn(List([1, 2, 3]), [99])).type.toBe<number>();

expect(getIn(List([1, 2, 3]), ['a' as const])).type.toBe<never>();

expect(
getIn(List([1, 2, 3]), ['a' as const], 'missing')
).type.toBe<'missing'>();

expect(getIn({ x: 10, y: 20 }, ['x' as const])).type.toBe<number>();

expect(
getIn({ x: 10, y: 20 }, ['z' as const], 'missing')
).type.toBe<'missing'>();

expect(getIn({ x: { y: 20 } }, ['x' as const])).type.toBe<{ y: number }>();

expect(getIn({ x: { y: 20 } }, ['z' as const])).type.toBe<never>();

expect(
getIn({ x: { y: 20 } }, ['x' as const, 'y' as const])
).type.toBe<number>();

expect(
getIn({ x: Map({ y: 20 }) }, ['x' as const, 'y' as const])
).type.toBe<number>();

expect(
getIn(Map({ x: Map({ y: 20 }) }), ['x' as const, 'y' as const])
).type.toBe<number>();

const o = Map({ x: List([Map({ y: 20 })]) });

expect(getIn(o, ['x' as const, 'y' as const])).type.toBe<never>();

expect(getIn(o, ['x' as const])).type.toBe<List<MapOf<{ y: number }>>>();

expect(getIn(o, ['x' as const, 0])).type.toBe<MapOf<{ y: number }>>();

expect(getIn(o, ['x' as const, 0, 'y' as const])).type.toBe<number>();
});

test('has', () => {
expect(has([1, 2, 3], 0)).type.toBeBoolean();

Expand Down
8 changes: 4 additions & 4 deletions type-definitions/ts-tests/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ test('#get', () => {
});

test('#getIn', () => {
const result = Map({ a: 4, b: true }).getIn(['a' as const]);
const result = Map({ a: 4, b: true }).getIn(['a']);

expect(result).type.toBeNumber();

Expand All @@ -100,9 +100,9 @@ test('#getIn', () => {
])
).type.toBeNumber();

// currently `RetrievePathReducer` does not work with anything else than `MapOf`
// TODO : fix this with a better type, it should be resolved to `number` (and not be marked as `fail`)
expect.fail(Map({ a: List([1]) }).getIn(['a' as const, 0])).type.toBeNumber();
expect(Map({ a: [1] }).getIn(['a' as const, 0])).type.toBeNumber();

expect(Map({ a: List([1]) }).getIn(['a' as const, 0])).type.toBeNumber();
});

test('#set', () => {
Expand Down