I want to flatten an object with the following shape
{
article: 'prova',
id: 63,
topology: { id: 'topId', label: 'topLabel' },
something: { id: 'someId', label: 'someLabel' }
}
into something with the following one
{
article: "prova",
id: 63,
topId: "topLabel",
someId: "someLabel"
}
Both the Input
and the Output
types must satisfy strict typings. Basically the following must hold true:
interface Input {
article: string
id: number
abc: { id: string; label: string }
def: { id: string; label: string }
}
interface Output {
article: string
id: number
topId: string
someId: string
}
const input1: Input = {
article: 'prova',
id: 63,
abc: { id: 'topId', label: 'topLabel' },
def: { id: 'someId', label: 'someLabel' }
}
const input2 = {
article: 'prova',
id: 63,
abc: { id: 'topId', label: 'topLabel' },
def: { id: 'someId', label: 'someLabel' }
}
// Type 'Flattened<string | number | null | undefined, { id: string; label: string; }, Record<string, string | number | { id: string; label: string; } | null | undefined>>' is missing the following properties from type 'Output': article, id, topId, someId
// Argument of type 'Input' is not assignable to parameter of type 'Record<string, string | number | { id: string; label: string; } | null | undefined>'.
// Index signature for type 'string' is missing in type 'Input'.
const output1: Output = flattenObject(input1)
// Type 'Flattened<string | number | null | undefined, { id: string; label: string; }, { article: string; id: number; abc: { id: string; label: string; }; def: { id: string; label: string; }; }>' is missing the following properties from type 'Output': topId, someId
const output2: Output = flattenObject(input2)
I've tried this tentative implementation but TypeScript isn't happy:
function hasId<
K extends string | number | undefined | null,
T extends { id: string; label: string }
>(el: [string, K | T]): el is [string, T] {
return (el[1] as T).id != null
}
type Flattened<
K extends string | number | undefined | null,
T extends { id: string; label: string },
S extends Record<string, K | T>
> = {
[key in keyof S as S[key] extends T ? S[key]['id'] : key]: S[key] extends T
? S[key]['label']
: S[key]
}
const flattenObject = <
K extends string | number | undefined | null,
T extends { id: string; label: string },
S extends Record<string, K | T>
>(
obj: S
): Flattened<K, T, S> =>
Object.fromEntries(
Object.entries(obj).map<[string, K | string]>((el) =>
hasId(el) ? [el[1].id, el[1].label] : (el as [string, K])
)
) as Flattened<K, T, S>
IMPORTANT CAVEAT: If you require that the input be annotated as type Input
as in
interface Input {
article: string
id: number
abc: { id: string; label: string }
def: { id: string; label: string }
}
const input: Input = { ⋯ };
then it is completely impossible for
const output: Output = flattenObject(input);
to work directly. TypeScript only knows that input
above is of type Input
, and so the type of input.abc.id
is just string
, and so is the type of input.def.id
. Any information about the values "topId"
and "someId"
has been lost. The best you'll get is flattenObject(input)
to produce a value of a type like {article: string; id: number} & {[x: string]: string}
, which isn't sufficient.
If you want this to work, you must let TypeScript know that input.abc.id
is of the literal type "topId"
and that input.def.id
is of the literal type type "someId"
. You could either do this by modifying Input
to be
interface Input {
article: string
id: number
abc: { id: "topId"; label: string }
def: { id: "someId"; label: string }
}
or by allowing input
to be of a narrower type than Input
like
const input = { ⋯ } as const satisfies Input;
which uses a const
assertion to keep track of the literal types of all string values in the object literal, and the satisfies
operator to make sure it's still assignable to Input
.
For the rest of this answer I assume you can do that.
I'd write Flattened<T>
and flattenObject
like
type Flattened<T extends object> =
{ [K in keyof T as T[K] extends IdLabel ? T[K]["id"] : K]:
T[K] extends IdLabel ? T[K]["label"] : T[K]
}
interface IdLabel { id: string; label: string }
declare const flattenObject: <T extends object>(
obj: T
) => Flattened<T>
Essentially, this is a key remapped mapped type the property of T
at each key K
is checked to see if its an IdLabel
or not. If it's an IdLabel
then the key is remapped to the id
property and the value is mapped to the label
property. Otherwise the key and value are left alone.
The actual implementation of flattenObject
is probably not in question here, but for completeness, you could write
function isIdLabel(x: any): x is IdLabel {
return !!x && (typeof x === "object") && ("id" in x) &&
(typeof x.id === "string") && ("label" in x) &&
(typeof x.label === "string");
}
const flattenObject = <T extends object>(
obj: T
): Flattened<T> =>
Object.fromEntries(Object.entries(obj).map(
([k, v]) => isIdLabel(v) ? [v.id, v.label] : [k, v])
) as any
Let's test it:
const input = {
article: 'prova',
id: 63,
abc: { id: 'topId', label: 'topLabel' },
def: { id: 'someId', label: 'someLabel' }
} as const satisfies Input;
/* const input: {
readonly article: "prova";
readonly id: 63;
readonly abc: {
readonly id: "topId";
readonly label: "topLabel";
};
readonly def: {
readonly id: "someId";
readonly label: "someLabel";
};
} */
const output = flattenObject(input) satisfies Output;
/* const output: {
readonly article: "prova";
readonly id: 63;
readonly topId: "topLabel";
readonly someId: "someLabel";
} */
Looks good. The type of output
is an appropriately Flatten
ed version of the type of input
.