TypeScript-typ-säker version av Lodashs Set?


Finns det ett sätt att lägga till TypeScript-typkontroll till Lodashs set-funktion?

Jag behöver sätta värden på djupt nästlade objekt. Egenskaperna kan vara odefinierade, och jag vill inte skriva över andra objekts egenskaper.

Den här koden fungerar:

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);
  }
}

Men det har ingen typkontroll, till exempel kommer inte detta ge ett fel:

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

Svar #1

Det finns en liknande funktion set i moderndash, men den saknar en typesäker set-existing-value-motsvarighet

(det fungerar precis som lodash's set, så du kan fritt import { set as setUntyped } from "lodash" istället)

Genom att modifiera dess typer till att vara a-la-set snarare än a-la-assign, är det enkelt att implementera en typesäker version av 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
}

För att använda det med din delade sträng behöver du också en string.split-överlagring:

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>;
    }
}

Då visar exempel-koden fel, precis som förväntat: 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)
    }
}

Svar #2

Först kommer vi att behöva få de möjliga vägar som vi kan få. Vi kommer att använda mapade typer för det. Vi kommer att behöva några verktyg:

Dot - sammanfogar till strängar till en sökväg:

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

DefaultStopTypes - typer på vilka vi bör sluta gå djupare

type DefaultStopTypes = number | string | boolean;

PathsToFields - accepterar ett objekt och stopp-typer. Traverserar objektet rekursivt och hämtar vägar till dess fält med punktnotation.

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

Exempel :

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

GetPropertyType - accepterar ett objekt och en väg till dess fält. Returnerar typen av egenskapen under den angivna vägen:

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;

Den återstående delen är att skriva set-funktionen, vilket är ganska enkelt:

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);
};

Användning :

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

Men det räcker inte i ditt fall eftersom du använder hjälpklasser som 'p-top-1' och split inte är typad, så du måste ange rätt typ med as-assertion.

Jag kan föreslå att lägga till följande typer:

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;

Omkoda varor enligt följande:

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

Det är fortfarande inte lösningen eftersom split kommer att returnera oss en string[]. Låt oss skapa en verktygstyp för split som returnerar exakt värdetyperna:

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];

Exempel :

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

Det är allt, men du kommer att behöva ange value som number, eftersom när du delar får du en 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);
  }
}

lekplats

Relaterade artiklar