Hur man djupt plattar ut ett Typescript-gränssnitt med unionstyper och behåller hela objektvägen i nyckeln
Säg att jag har följande gränssnitt:
interface Foo {
foo: string
bar?: number
nested: {
foo: string,
deeplyNested: {
bar?: number
}
}
union: string | {
foo: string,
bar?: number
}
}
Nu behöver jag en typ för att konvertera detta gränssnitt till en platt version med de fullständiga sökvägsnamnen i nycklarna så här:
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
}
Typen bör fungera för vilken nivå av nästling som helst och vilket antal föreningsmedlemmar som helst.
Jag har redan hittat en relaterad fråga här men den är bara ett mycket grundläggande gränssnitt utan unioner eller valfria parametrar.
Svar #1
Det finns många sätt att närma sig detta, och enligt min erfarenhet är de alla krångliga och pilliga och ömtåliga och har många tokiga kantfall. Se Hur kan en objekttyp med inbäddade underegenskaper plattas ut? för en liknande fråga med ett lika fullproppat svar med förbehåll.
I slutändan, i detta fall bestämde jag mig för att ta följande tillvägagångssätt. Vi kan ta en objekttyp och representera den som en union av Entry-element, där en Entry har en key, value och optional-egenskap:
type Entry = { key: string, value: any, optional: boolean };
Så för en typ som {a: string, b?: number} skulle du få {key: "a", value: string, optional: false} | {key: "b", value: number | undefined, optional: true}.
För det första steget skulle vi ta inmatningsgränssnittet som Foo och Explode det till en stor union av Entry-element där djupt nästlade egenskaper omvandlas till enskilda nycklar med punktbaserade vägar. Så Explode<{a: {b: string}}> skulle bli {key: "a.b", value: string, optional: false}. Detta utför den tunga delen av algoritmen, och vi måste fatta beslut om saker som hur valfria egenskaper sprids, hur unioner sprids, etc.
En möjlig definition av Explode skulle kunna se ut så här:
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 }
Om vi Explode en icke-objekttyp använder vi en tom nyckel; annars Explode vi alla objektets egenskaper rekursivt och sedan sammanfogar nycklar med hjälp av template literal-typer, och bestämmer något med valfria egenskaper (jag tror att jag gjorde det så att {a: {b?: string}} skulle göra b valfri, men {a?: {b: string}} skulle göra b obligatorisk). Det är ett kaos.
Oh och jag var inte säker på vad jag skulle göra med arrayer; jag bestämde mig för att bara använda "0" som nyckel för att representera ett arrayindex, och så bytte jag namn på Explode<T> till _Explode<T> och definierade sedan Explode<T> utifrån det:
type Explode<T> = _Explode<T extends readonly any[] ? { "0": T[number] } : T>;
Slutligen, när vi har den fullständiga exploderade unionen av Entry-element, kan vi Collapse dem tillsammans till en enda objekttyp:
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
Jag använder tangentommapping via as för att iterera över varje element E i unionen T av Entry-objekt; varje nyckel är E['key'] och varje värde är E['value']. Genom att dela upp unionen i de med true och false värden för optional-egenskapen kan vi skapa en outputtyp med respektive valfria och obligatoriska egenskaper.
Och slutligen, Flatten<T> är precis vad du gör när du Explode något till Entry-objekt och sedan Collapse dessa objekt:
type Flatten<T> = Collapse<Explode<T>>
Så här fungerar det på ditt exempel här:
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;
}*/
vilket är exakt samma typ som ditt manuellt skrivna förväntade resultat (förutom ordningen på egenskaperna, vilket inte påverkar typen likhet).
För något mer komplext, som
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
}
vi får
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;
} */
vilket är rimligt, tror jag.
Självklart finns det troligen kantfall där detta kommer att göra något du inte vill. De kan kanske åtgärdas genom att ändra hur Explode fungerar, eller så kanske inget du gör fungerar för alla dina avsedda användningsfall och du kommer att behöva bestämma dig för antingen att ge upp några av dina användningsfall, eller ge upp Flatten.
Playground-länk till kod
Svar #2
Först kan vi arbeta med att skapa en typ som innehåller en union av de giltiga punkterade vägarna för ditt objekt. Som tur är har vi redan ett utmärkt svar som ger en metod för att göra det precis här.
Med hjälp av typen Leaves från det svaret kan vi konstruera din union av vägar:
type DottedFooPaths = Leaves<Foo>;
// type DottedFooPaths = "foo" | "bar" | "union" | "nested.foo" | "nested.deeplyNested.bar" | "union.foo" | "union.bar"
Nu behöver vi skapa ett nytt objekt med dessa sökvägar som nycklar, och koppla varje sökväg till dess motsvarande värdetyp i det ursprungliga objektet. För att uppnå det kan vi utföra punkt-sökvägs-konverteringen i omvänd riktning, och använda det för att indexera i vår ursprungliga typ rekursivt:
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
Då, om vi konstruerar en typ där varje nyckel är en av medlemmarna i Leaves<Foo>, kan vi använda FollowPath för att extrahera motsvarande värdetyp:
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;
// }
Vilket är nästan ditt önskade resultat, men det förlorar de valfria modifikatorerna på tangenterna; även om jag är osäker på om det ens finns något sätt att behålla de valfria modifikatorerna.
