TypeScript类型安全版本的Lodash集?

xoshrz7s  于 2023-05-23  发布在  TypeScript
关注(0)|答案(3)|浏览(204)

bounty还有2天到期。回答此问题可获得+250声望奖励。Evanss希望引起更多关注这个问题。

有没有办法将TypeScript类型安全添加到Lodash的set函数中?
我需要在深度嵌套的对象上设置值。属性可以是未定义的,我不想覆盖其他对象属性。
这段代码的工作原理:

import set from "lodash/set";

type Res = {
  position?: {
    top?: { value: number },
    right?: { value: number }
  },
  color?: {
    red?: number,
    blue?: number
  }
};

const res : Res = {};

const items = ["p-top-1", "p-right-2", 'c-red-300'];

for (let i = 0; i < items.length; i++) {
  const item = items[i];
  const [type, key, value] = item.split('-');

  if(type === 'p') {
    set(res, `position.${key}.value`, value);
  }

  if(type === 'c') {
    set(res, `color.${key}.value`, value);
  }
}

但它没有类型安全,例如这不会出错:

if(type === 'p') {
    set(res, `whatever-key-here.${key}.value`, value);
  }
blmhpbnm

blmhpbnm1#

moderndash中有类似的函数set,但它缺少一个类型安全的set-existing-value对应函数
(it工作原理与lodashset完全相同,因此您可以自由地使用import { set as setUntyped } from "lodash"
通过将其类型修改为a-la-set而不是a-la-assign,可以直接实现set的类型安全版本:

import { set as setUntyped } from "moderndash";
import type { Call, Objects, Strings } from "hotscript";
import type { PlainObject } from "moderndash";
export function set<TObj extends PlainObject, TPath extends Call<Objects.AllPaths, TObj>>(
    obj: TObj, path: TPath, value: Call<Objects.Get<TPath>, TObj>
): TObj {
    return setUntyped(obj, path, value) as TObj
}

要将它用于拆分字符串,还需要string.split重载:

declare global {
    interface String {
        // typesafe 's-t-r-i-n-g'.split('-'): ['s', 't', 'r', 'i', 'n', 'g']
        split<S extends string, D extends string>(this: S, separator: D): Call<Strings.Split<D>, S>;
    }
}

然后示例代码显示错误,正如预期的那样:Playground:https://tsplay.dev/Nn886W

const res: Res = {};

const items = ["p-top-1", "p-right-2", 'c-red-300'] as const;

for (let i = 0; i < items.length; i++) {
    const item = items[i];
    const [type, key, value] = item.split('-')

    if (type === 'p') {
        set(res, `position.${key}.value`, value);
        //                                 ^!
        // Argument of type '"1" | "2"' is not assignable to parameter of type 'number | undefined'.
    }

    if (type === 'c') {
        set(res, `color.${key}.value`, value);
        //                              ^!
        // Argument of type '"300"' is not assignable to parameter of type 'undefined'.(2345)
    }
}
vwhgwdsa

vwhgwdsa2#

首先,我们需要得到我们可以得到的可能路径。我们将使用mapped types。我们需要一些utils:
Dot-连接到一个路径的字符串:

type Dot<T extends string, U extends string> = '' extends U
  ? T
  : `${T}.${U}`;

DefaultStopTypes-我们应该停止深入研究的类型

type DefaultStopTypes = number | string | boolean;

PathsToFields-接受对象和停止类型。递归遍历对象并获取其字段的路径,并使用点表示法。

type PathsToFields<T, StopTypes = DefaultStopTypes> = T extends DefaultStopTypes
  ? ''
  : {
      [K in Extract<keyof T, string>]: Dot<K, PathsToFields<T[K], StopTypes>>;
    }[Extract<keyof T, string>];

示例:

// type PathsToRes = "position.top.value" | "position.right.value" | "color.red" | "color.blue"
type PathsToRes = PathsToFields<Res>

GetPropertyType-接受一个对象的字段路径。返回给定路径下属性的类型:

type GetPropertyType<T, K extends string> = K extends keyof T
  ? T[K]
  : K extends `${infer Property}.${infer SubField}`
  ? Property extends keyof T
    ? GetPropertyType<NonNullable<T[Property]>, SubField>
    : never
  : never;

剩下的部分是编写set函数,这很简单:

const set = <
  T extends Record<string, any>,
  P extends PathsToFields<T>,
  V extends GetPropertyType<T, P>,
>(
  obj: T,
  path: P,
  value: V,
) => {
  const chunks = path.split('.');
  chunks.reduce<Record<string, any>>((acc, chunk, index) => {
    acc[chunk] ??= {};

    if (index === chunks.length - 1) acc[chunk] = value;

    return acc[chunk];
  }, obj);
};

用途:

set(res, 'color.blue', 123) // no error expected
set(res, 'position.right.value', 123) // no error expected
set(res, 'incorrect.right.value', 'incorrect') // incorrect key
set(res, 'color.blue', 'invalid value') // incorrect value

然而,在您的情况下,这是不够的,因为您使用的是实用类,例如'p-top-1',并且split不是类型化的,所以您必须使用asAssert来Assert正确的类型。
我可以建议增加以下类型:

type Position = 'top' | 'right';
type Color = 'red' | 'blue';

type StyleCreator<T extends string> = `${string}-${T}-${string}`

// type PositionStyle = `${string}-top-${string}` | `${string}-right-${string}`
type PositionStyle = StyleCreator<Position>;

// type ColorStyle = `${string}-red-${string}` | `${string}-blue-${string}`
type ColorStyle = StyleCreator<Color>;

type Style = PositionStyle | ColorStyle;

重新申报项目如下:

const items = ['p-top-1', 'p-right-2', 'c-red-300'] satisfies Style[];

这仍然不是解决方案,因为拆分将返回一个string[]。让我们为拆分创建一个实用程序类型,它返回确切的值类型:

type Split<Str extends string, Del extends string> = string extends Str
  ? string[]
  : '' extends Str
  ? []
  : Str extends `${infer T}${Del}${infer U}`
  ? [T, ...Split<U, Del>]
  : [Str];

示例:

// type Splitted = ["p", "top", "1"]
type Splitted = Split<'p-top-1', '-'>

就是这样,尽管你必须将valueAssert为number,因为当你拆分的时候你会得到一个string

for (let i = 0; i < items.length; i++) {
  const item = items[i];
  const [type, key, value] = item.split('-') as Split<typeof item, '-'>;

  if (type === 'p') {
    set(res, `position.${key}.value`, +value);
  }

  if (type === 'c') {
    set(res, `color.${key}`, +value);
  }
}

Playground

ssgvzors

ssgvzors3#

你是对的,lodash的set函数不能很好地与TypeScript的静态类型检查一起使用。TypeScript是一种静态类型语言,lodash的set函数本质上是动态的。这意味着lodash的set函数中使用的属性路径是一个字符串,可以是任何东西,所以TypeScript在编译时无法检查它。
在这里添加一些类型安全性的一种方法是将可以与lodash的set函数一起使用的属性限制为在您的类型中定义的属性。这并不能完全解决问题,但至少可以限制问题的范围。
例如,我们可以使用TypeScript的keyof和conditional类型来帮助我们。让我们定义一个helper函数:

import set from "lodash/set";

type Res = {
  position?: {
    top?: { value: number },
    right?: { value: number }
  },
  color?: {
    red?: number,
    blue?: number
  }
};

type PositionKeys = 'top' | 'right';
type ColorKeys = 'red' | 'blue';

function setRes<T extends PositionKeys | ColorKeys>(
  res: Res,
  type: 'p' | 'c',
  key: T,
  value: number,
): void {
  if (type === 'p' && isPositionKey(key)) {
    set(res, `position.${key}.value`, value);
  } else if (type === 'c' && isColorKey(key)) {
    set(res, `color.${key}`, value);
  }
}

function isPositionKey(key: string): key is PositionKeys {
  return key === 'top' || key === 'right';
}

function isColorKey(key: string): key is ColorKeys {
  return key === 'red' || key === 'blue';
}

const res : Res = {};

const items = ["p-top-1", "p-right-2", 'c-red-300'];

for (let i = 0; i < items.length; i++) {
  const item = items[i];
  const [type, key, value] = item.split('-');

  setRes(res, type as 'p' | 'c', key as PositionKeys | ColorKeys, Number(value));
}

在本例中,如果您使用的任何属性键未在PositionKeys或ColorKeys类型中定义,TypeScript将给予编译时错误。这不是一个完美的解决方案,但它确实增加了安全性。
请注意,TypeScript仍然无法验证传递给lodash的set函数的属性路径字符串的正确性。你可以传递一个错误的属性路径,比如'position.notExistingProp.value',TypeScript将无法捕获该错误。

相关问题