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
