The following solution is a bit verbose. I am sure someone will find a shorter solution :)
export type Postfix<K extends string, T extends string> = `${T}${K}`;
type ExpandRecursively<T> = T extends object
? T extends infer O ? { [K in keyof O]: ExpandRecursively<O[K]> } : never
: T;
type UnionToIntersection<U> =
(U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type Postfixer<T, K extends string> = ExpandRecursively<
UnionToIntersection<
{
[P in keyof T]-?: [
| XOR<{ [Key in Postfix<K, string & P>]: T[P] }, { [Key in P]: T[P] }>
| (undefined extends T[P]
? { [Key in Postfix<K, string & P> | P]: undefined }
: never)
];
}[keyof T]
>
>[any];
type Foo = {
one: string;
two: string;
three?: string;
}
type DuplicatedAndRenamed = Postfixer<Foo, ".$">;
The goal here is to create a union of all possible key combinations that an object of type DuplicatedAndRenamed
may have. The first two elements of the union would look like this:
{
"one.$"?: undefined;
one: string;
"two.$"?: undefined;
two: string;
"three.$"?: undefined;
three?: string | undefined;
} | {
"one.$"?: undefined;
one: string;
"two.$"?: undefined;
two: string;
three?: undefined;
"three.$": string | undefined;
} | ... 9 more ... | {
...;
}
As you can see, each element of the union specifies exactly which properties must be defined and which must be undefined
for each possible case.
This implementation uses two handy utility types. XOR
from this answer will allow us to enforce that either the key with the postfix is allowed or the one without, but not both.
We also need UnionToIntersection
from this answer to convert unions to intersections (as the name already implies).
For the implementation of Postfixer
we map over the keys of T
. For each key we create a tuple containing the XOR
'd type of the key and postfixed key. If the key of T
is optional we also add another element to the union where both keys are undefined
.
| (undefined extends T[P]
? { [Key in Postfix<K, string & P> | P]: undefined }
: never)
As you can see, the computed XOR
type is wrapped inside a tuple. This will keep the UnionToIntersection
from intersecting the XOR
'd type. After we intersected the unions of all the mapped keys, we only need to access the resulting indexed type with [any]
.
Let's see if this passes all tests:
// works
const shouldWork1: DuplicatedAndRenamed = {
"one.$": 'foo',
"three.$": 'bar',
"two.$": 'baz',
}
// works: mix of postfix and non-postfix
const shouldWork2: DuplicatedAndRenamed = {
"one.$": 'foo',
three: 'bar',
"two.$": 'baz',
}
// works: three is not here because optional
const shouldWork3: DuplicatedAndRenamed = {
"one.$": 'foo',
"two.$": 'baz',
}
// Error: three and three.$ are not allowed together
const shouldNotWork: DuplicatedAndRenamed = {
"one.$": 'foo',
three: 'bar',
"three.$": 'bar',
"two.$": 'baz',
}
Playground