Cum să aplatizezi profund o interfață Typescript cu tipuri uniune și să păstrezi calea completă a obiectului în cheie
Spune că am următoarea interfață:
interface Foo {
foo: string
bar?: number
nested: {
foo: string,
deeplyNested: {
bar?: number
}
}
union: string | {
foo: string,
bar?: number
}
}
Acum am nevoie de un tip pentru a converti această interfață într-o versiune aplatizată, cu numele complet al căilor în chei, astfel:
interface FooFlattened {
foo: string
bar?: number
"nested.foo": string
"nested.deeplyNested.bar"?: number
// if union is a string:
union: string
// if union is an object:
"union.foo": string
"union.bar"?: number
}
Tipul ar trebui să funcționeze pentru orice nivel de încuibare și orice număr de membri ai uniunii.
Am găsit deja o întrebare legată aici, dar cea respectivă are doar o interfață foarte simplă, fără uniuni sau parametri opționali.
Răspuns #1
Există multe moduri de a aborda acest lucru și, din experiența mea, toate sunt complicate, migăloase și fragile și au multe cazuri limită ciudate. Vezi Cum poate fi aplatizat un tip de obiect cu subproprietăți încorporate? pentru o întrebare similară cu un răspuns la fel de plin de avertismente.
La sfârșit, în acest caz am decis să adopt următoarea abordare. Putem lua un tip de obiect și să îl reprezentăm ca o uniune de elemente Entry, unde un Entry are o proprietate key, value și optional:
type Entry = { key: string, value: any, optional: boolean };
Deci, pentru un tip precum {a: string, b?: number} ai obține {key: "a", value: string, optional: false} | {key: "b", value: number | undefined, optional: true}.
Pentru primul pas, am lua interfața de intrare cum ar fi Foo și am Exploda aceasta într-o uniune mare de elemente Entry, unde proprietățile profund imbricate sunt convertite în chei unice cu căi separate prin puncte. Astfel, Explode<{a: {b: string}}> ar deveni {key: "a.b", value: string, optional: false}. Acesta realizează partea grea a algoritmului și trebuie să decidem lucruri precum modul în care proprietățile opționale se propagă, modul în care uniunile se propagă etc.
O posibilă definiție a Explode ar putea arăta astfel:
type Explode<T> =
T extends object ? { [K in keyof T]-?:
K extends string ? Explode<T[K]> extends infer E ? E extends Entry ?
{
key: `${K}${E['key'] extends "" ? "" : "."}${E['key']}`,
value: E['value'],
optional: E['key'] extends "" ? {} extends Pick<T, K> ? true : false : E['optional']
}
: never : never : never
}[keyof T] : { key: "", value: T, optional: false }
Dacă Explode un tip non-obiect folosim o cheie goală; altfel, Explode recursiv toate proprietățile obiectului și apoi concatenăm cheile folosind tipuri de literal template, și decidem ceva cu proprietățile opționale (cred că am făcut astfel încât {a: {b?: string}} să facă b opțional, dar {a?: {b: string}} să facă b obligatoriu). E un haos.
Oh și nu eram sigur ce să fac cu array-urile; am decis să folosesc pur și simplu "0" ca și cheie pentru a reprezenta un index de array, așa că am redenumit Explode<T> în _Explode<T> și apoi am definit Explode<T> în termeni de acesta:
type Explode<T> = _Explode<T extends readonly any[] ? { "0": T[number] } : T>;
În cele din urmă, odată ce avem întreaga uniune extinsă a elementelor Entry, le putem Collapse împreună într-un singur tip de obiect:
type Collapse<T extends Entry> = (
{ [E in Extract<T, { optional: false }> as E['key']]: E['value'] }
& Partial<{ [E in Extract<T, { optional: true }> as E['key']]: E['value'] }>
) extends infer O ? { [K in keyof O]: O[K] } : never
Folosesc remaparea cheilor prin as pentru a itera peste fiecare element E al uniunii T de obiecte Entry; fiecare cheie este E['key'] și fiecare valoare este E['value']. Prin împărțirea uniunii în elementele cu valori true și false pentru proprietatea optional, putem produce un tip de ieșire cu proprietăți opționale și necesare, respectiv.
Și, în cele din urmă, Flatten<T> este exact ceea ce faci când Explode ceva în obiecte Entry și apoi Collapse acele obiecte:
type Flatten<T> = Collapse<Explode<T>>
Iată cum funcționează pe exemplul tău de aici:
type FooFlattened = Flatten<Foo>
/* type FooFlattened = {
foo: string;
"nested.foo": string;
union: string;
"union.foo": string;
bar?: number | undefined;
"nested.deeplyNested.bar"?: number | undefined;
"union.bar"?: number | undefined;
}*/
care este exact același tip ca și rezultatul așteptat scris manual de tine (cu excepția ordinii proprietăților, care nu afectează egalitatea tipului).
Pentru ceva mai complex, cum ar fi
interface Foo {
tmdb: number | {
title: {
original: string
german?: string
}
budget?: number
revenue?: number
tagline?: string
overview?: string
productionCompanies?: {
id?: number
logoPath?: string
name?: string
originCountry?: string
}[]
releaseDate?: string
genres?: string[]
runtime?: number
poster?: string | {
data: { sample: any }
contentType: string
}
}
rating: { ch: number; rt: number } | { total: number }
dateSeen?: Date
fsk?: number
mm?: boolean
}
noi obținem
type FlattenedFoo = Flatten<Foo>
/* type FlattenedFoo = {
tmdb: number;
"tmdb.title.original": string;
"tmdb.genres.0": string;
"tmdb.poster.data.sample": any;
"tmdb.poster.contentType": string;
"rating.ch": number;
"rating.rt": number;
"rating.total": number;
"tmdb.title.german"?: string | undefined;
"tmdb.budget"?: number | undefined;
"tmdb.revenue"?: number | undefined;
"tmdb.tagline"?: string | undefined;
"tmdb.overview"?: string | undefined;
"tmdb.productionCompanies"?: undefined;
"tmdb.productionCompanies.0.id"?: number | undefined;
"tmdb.productionCompanies.0.logoPath"?: string | undefined;
"tmdb.productionCompanies.0.name"?: string | undefined;
"tmdb.productionCompanies.0.originCountry"?: string | undefined;
"tmdb.releaseDate"?: string | undefined;
"tmdb.genres"?: undefined;
"tmdb.runtime"?: number | undefined;
"tmdb.poster"?: string | undefined;
dateSeen?: undefined;
fsk?: number | undefined;
mm?: boolean | undefined;
} */
ceea ce este rezonabil, cred.
Desigur, există probabil cazuri-limită în care aceasta va face ceva ce nu doriți. Ele ar putea fi rezolvate prin schimbarea modului în care funcționează Explode, sau poate că nimic din ceea ce faceți nu va funcționa pentru toate cazurile de utilizare dorite și va trebui să decideți fie să renunțați la unele dintre cazurile de utilizare, fie să renunțați la Flatten.
Link de teren de joacă către cod
Răspunsul #2
Mai întâi putem lucra pentru a crea un tip care conține o uniune a căilor separate prin puncte valide pentru obiectul tău. Din fericire, avem deja un răspuns excelent care oferă o metodă de a face acest lucru chiar aici.
Folosind tipul Leaves din acel răspuns, putem construi uniunea ta de căi:
type DottedFooPaths = Leaves<Foo>;
// type DottedFooPaths = "foo" | "bar" | "union" | "nested.foo" | "nested.deeplyNested.bar" | "union.foo" | "union.bar"
Acum trebuie să creăm un obiect nou folosind acele căi ca și chei și să asociem fiecărei căi tipul valorii corespunzătoare din obiectul original. Pentru a realiza acest lucru, putem efectua conversia din calea punctată invers și să folosim acest lucru pentru a indexa în tipul nostru original recursiv:
type FollowPath<T, P> = P extends `${infer U}.${infer R}` ? U extends keyof T ? FollowPath<T[U], R> : never : P extends keyof T ? T[P] : never;
// For example, this might produce:
type FooDotBar = FollowPath<Foo, "bar"> // number | undefined
Apoi, dacă construim un tip în care fiecare cheie este unul dintre membrii Leaves<Foo>, putem folosi FollowPath pentru a extrage tipul valorii corespunzătoare:
type FlattenedFoo = {
[K in Leaves<Foo>]: FollowPath<Foo, K>
}
// Produces:
// type FlattenedFoo = {
// foo: string;
// bar: number | undefined;
// union: string | {
// foo: string;
// bar?: number | undefined;
// };
// "nested.foo": string;
// "nested.deeplyNested.bar": number | undefined;
// "union.foo": never;
// "union.bar": never;
// }
Care este aproape rezultatul dorit, dar pierde modificatorii opționali de pe taste; deși nu sunt sigur dacă există măcar o modalitate de a păstra modificatorii opționali.
