I want to map custom type classes and expect them to work seemlessly with TypeScript's Mapped Types.
The issue is that when I use a callback, the result will be expanded(?) to a union, which I don't want.
I believe the code speaks for itself
const any = (any?: any) => any;
// CONSTRAINTS
namespace Constraint {
export type OBJ = { [K: string]: ANY };
export type OPT = ANY;
// @ts-ignore // I know how to fix this, but please ignore for now! Thank you :)
export type ANY = Str | Num | Obj<OBJ> | Opt<ANY>;
}
// TYPES
type Nested<T extends { nested: unknown }> = T['nested'];
type Opt<T extends Constraint.OPT> = OPT<T>;
type Str = STR;
type Num = NUM;
type Obj<T extends Constraint.OBJ> = OBJ<T>;
// CLASSES
class OPT<T extends Constraint.OPT> {
opt: undefined;
constructor(public nested: T) {}
}
class OBJ<T extends Constraint.OBJ> {
obj: undefined;
public map<R extends Constraint.ANY>(
cb: (value: Nested<this>[keyof Nested<this>]) => R
): Obj<{
[K in keyof Nested<this>]: R;
}> {
return any(cb);
}
constructor(public nested: T) {}
}
class STR {
str: undefined;
}
class NUM {
num: undefined;
}
// FUNCTIONS
const Opt = <T extends Constraint.OPT>(nested: T): Opt<T> => new OPT<T>(nested);
const Obj = <T extends Constraint.OBJ>(nested: T): Obj<T> => new OBJ<T>(nested);
const Str = (): Str => new STR();
const Num = (): Num => new NUM();
// USE
const example = Obj({
one: Str(),
two: Num(),
});
export const result = example.map((value) => Opt(value)); // => See below:
I get this:
Obj<{
one: Opt<STR | NUM>;
two: Opt<STR | NUM>;
}>
But I really just want this:
Obj<{
one: Opt<STR>;
two: Opt<NUM>;
}>
Would I need HKTs (Higher-Kinded Types) for this, as described here, or can I solve it another way?
Luckily, I did not give up, and found a great solution.
By providing the desired wrapped return type either as generic or as value, I could simulate HKTs.
The idea is to first find the unique symbol
inside the wanted return type, and then replacing it with the given type at the given index (or key).
Like this:
const US: unique symbol = Symbol('US');
type US = typeof US;
type Recurse<V, T> = V extends US
? T
: V extends Obj<infer O>
? Obj<{ [K in keyof O]: Recurse<O[K], T> }>
: V extends Opt<infer O>
? Opt<Recurse<O, T>>
: V extends object
? { [K in keyof V]: Recurse<V[K], T> }
: never;
Now to make the callback typesafe I have to replace the US
with all possible value types of Nested<this>
.
For that I have a very similar looking utility type:
type ReplaceValue<V, T> = V extends US
? T
: V extends Obj<infer O>
? Obj<{ [K in keyof O]: ReplaceValue<O[K], T> }>
: V extends Opt<infer O>
? Opt<ReplaceValue<O, T>>
: V extends object
? { [K in keyof V]: ReplaceValue<V[K], T> }
: never;
Finally, I have two utilities to "compute" and "simplify" the result:
// You can not name it `Magic` if you don't like it.
type Magic<V, T extends Obj<any>> = Obj<{
[K in keyof Nested<T>]: Recurse<V, Nested<T>[K]>;
}>;
type Simple<T> = T extends Obj<infer V> ? Obj<V> : never;
The final map function/-s look/-s like this:
public map<R extends Constraint.ANY>(
cb: <N extends Nested<this>, K extends keyof N>(value: N[K]) => ReplaceValue<R, N[K]>
): Simple<Magic<R, this>> {
return any(cb);
}
public mapNoGeneric<R extends Constraint.ANY>(
expected: R,
cb: <N extends Nested<this>, K extends keyof N>(value: N[K]) => ReplaceValue<R, N[K]>
): Simple<Magic<R, this>> {
return any(cb);
}
And are used like this
export const result = example.map<Opt<US>>((value) => Opt(value));
export const result2 = example.mapNoGeneric(Opt(US), (value) => Opt(value)); // could probably even be done without the callback!
Finally the whole code in a Playground.