Tinkoff/utils.js

Types improvement for `object/path` util

temoncher opened this issue · 4 comments

Problem

For now there are two problems with object/path types:

  1. path doesn't infer type of a prop based on the strings inside array in all cases except a single value array
const user = {
  nestedProp: {
    name: 'John'
  },
};

const userName = path(['nestedProp', 'name'], user); // results in `unknown` type
  1. using a type argument is not typesafe and repetative
// can do the trick
// but we throw all the typesafety away, because type of `name` can change in the future
// and this dirty cast will only error out in runtime.
// Also it feels like we gave compiler all the information it needs to infer this type
// and it feels weird that we have to add types ourselves
const userName1 = path<string>(['nestedProp', 'name'], user);

Solutions

I see two solutions here: implement ValueByPath helper type ourselves or use https://github.com/millsp/ts-toolbelt util
Both solutions are available in codesandbox: https://codesandbox.io/s/relaxed-lewin-e3njhm?file=/src/index.ts

Manual

import type { Prop, Paths } from "@tinkoff/utils/typings/types";
import curryN from "@tinkoff/utils/function/curryN";

type IsExactKey<T> = string extends T
  ? false
  : number extends T
  ? false
  : symbol extends T
  ? false
  : true;

type ValueByPath<P extends Paths, O, U = true> = P extends readonly [
  infer F,
  ...(infer R)
]
  ? F extends keyof O
    ? R extends []
      ? U extends true
        ? O[F] | undefined
        : O[F]
      : R extends Paths // In case we run into some kind of dynamic dictionary // something like Record<string, ..> or Record<number, ..> // We want to make sure that we get T | undefined instead of T as a result
      ? IsExactKey<keyof O> extends true
        ? ValueByPath<R, O[F], U>
        : ValueByPath<R, O[F], true>
      : undefined
    : undefined
  : undefined;

type Path = {
  (pathToProp: Prop[]): (obj: object) => unknown;
  (pathToProp: Prop[], obj: object): unknown;
  <P extends Paths>(pathToProp: P): <O>(obj: O) => ValueByPath<P, O>;
  <P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;
};

const _path = <K extends Prop, O extends Record<K, any>>(
  paths: Paths = [],
  obj: O = {} as any
) => {
  let val = obj;

  for (let i = 0; i < paths.length; i++) {
    if (val == null) {
      return undefined;
    }

    val = val[paths[i]];
  }

  return val;
};

export const path = curryN(2, _path) as Path;

TS-toolbelt

import type { Any, Object } from "ts-toolbelt";
import curryN from "@tinkoff/utils/function/curryN";

type Path = {
  (pathToProp: Any.Key[]): (obj: object) => unknown;
  (pathToProp: Any.Key[], obj: object): unknown;
  <P extends readonly Any.Key[]>(pathToProp: P): <O>(
    obj: O
  ) => Object.Path<O, P>;
  <P extends readonly Any.Key[], O>(pathToProp: P, obj: O): Object.Path<O, P>;
};

const _path = <K extends Any.Key, O extends Record<K, any>>(
  paths: readonly Any.Key[] = [],
  obj: O = {} as any
) => {
  let val = obj;

  for (let i = 0; i < paths.length; i++) {
    if (val == null) {
      return undefined;
    }

    val = val[paths[i]];
  }

  return val;
};

export const pathT = curryN(2, _path) as Path;

Migration problem

In case we implement any of these solutions some of the library dependants can have migration issues, because they could have leaned on the usage of type parameters and now the generic is completely different. To provide a more smooth migration story we can add two more overloads with a single type parameter, mark them as deprecated and remove them completely in future versions

type Path = {
  (pathToProp: Prop[]): (obj: object) => unknown;
  (pathToProp: Prop[], obj: object): unknown;
  /** @deprecated please use `path` without type parameters instead */
  <T>(pathToProp: Prop[]): (obj: object) => T;
  /** @deprecated please use `path` without type parameters instead */
  <T>(pathToProp: Prop[], obj: object): T;
  <P extends Paths>(pathToProp: P): <O>(obj: O) => ValueByPath<P, O>;
  <P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;
};

Thank you for reporting!

Could you please update your example for the manual solution to make it work without any migration concerns, as it doesn't work for me right now.

image

my forked sandbox

Also, I'm thinking to neglect the migration issues by using default type for the generic and check that it was specified explicitly
e.g. smth like this

<P = Paths, O = object>(pathToProp: P extends Paths ? P: Paths, obj: O): P extends Paths ? ValueByPath<P, O> : P

Sure, I will take a look at the issue

Default type solution

About the default type, I don't think it is a good idea.
It seems like

<P = Paths, O = object>(pathToProp: P extends Paths ? P: Paths, obj: O): P extends Paths ? ValueByPath<P, O> : P

is the same as

  <T>(pathToProp: Prop[], obj: object): T; // `T` will default to `unknown`
  <P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;

except in first snippet we use ternary two times to express overloading behavior.

I think overloads in this case separte types into two clear and explicit tracks that are easier to follow.
Imo

  1. conditional types are pretty hard to read and understand, so they should be avoided if possible
  2. explicit overloads are more readable than implicit overloads with ternaries

Also as a user of path I expect a type parameter named P and defaulted to Paths to be responsible for path, but in this case it is sometimes path, but other times it is a dirty cast for a return type.

Also I don't think we will be able to infer type of P properly because of this ternary in place of pathToProp. I think it will always fall back to Paths

Fixed:

  1. single property access
  2. traversal thorugh optional/nullable property

Also cleaned up ValueByPath with helper types

Code

import type { Prop, Paths } from "@tinkoff/utils/typings/types";

import curryN from "@tinkoff/utils/function/curryN";

type If<B, F, S> = B extends true ? F : S;
type Or<B1, B2> = B1 extends true ? true : B2;
type Not<B> = B extends true ? false : true;
type Has<U extends any, U1 extends any> = [U1] extends [U] ? true : false;

type IsExactKey<T> = string extends T
  ? false
  : number extends T
  ? false
  : symbol extends T
  ? false
  : true;

type ValueByPath<P, O, U = false> = P extends readonly [infer F, ...(infer R)]
  ? /**
     * In case we accessed optional property of O on the previous step
     * O can be `undefined`. keyof operator won't work on a union
     */
    Or<Has<O, null>, Has<O, undefined>> extends infer HasNullOrUndefined
    ? Exclude<O, undefined | null> extends infer O2
      ? F extends keyof O2
        ? R extends []
          ? If<Or<U, HasNullOrUndefined>, O2[F] | undefined, O2[F]>
          : ValueByPath<
              R,
              O2[F],
              Or<
                Or<U, HasNullOrUndefined>,
                /**
                 * In case we run into some kind of dynamic dictionary
                 * something like Record<string, ..> or Record<number, ..>
                 * We want to make sure that we get T | undefined instead of T as a result
                 */
                Not<IsExactKey<keyof O2>>
              >
            >
        : undefined
      : never
    : never
  : never;

type Path = {
  (pathToProp: Prop[]): (obj: object) => unknown;
  (pathToProp: Prop[], obj: object): unknown;
  /** @deprecated please use `path` without type parameters instead */
  <T>(pathToProp: Prop[]): (obj: object) => T;
  /** @deprecated please use `path` without type parameters instead */
  <T>(pathToProp: Prop[], obj: object): T;
  <P extends Paths>(pathToProp: P): <O>(obj: O) => ValueByPath<P, O>;
  <P extends Paths, O>(pathToProp: P, obj: O): ValueByPath<P, O>;
};

const _path = <K extends Prop, O extends Record<K, any>>(
  paths: Paths = [],
  obj: O = {} as any
) => {
  let val = obj;

  for (let i = 0; i < paths.length; i++) {
    if (val == null) {
      return undefined;
    }

    val = val[paths[i]];
  }

  return val;
};

export const path = curryN(2, _path) as Path;

CodeSandbox(same link as before): https://codesandbox.io/s/relaxed-lewin-e3njhm?file=/src/index.ts

I suggested default type only because I thought interface overloads screwed up type inference and as you fixed them it is better use overloads of course.

So far, your suggestion looks great for me and it will be great if you could also provide a pr for it.
I think it is better for now to use the version without any additional libraries nevertheless it has very complex type output sometimes