superstruct
superstruct copied to clipboard
Impossible to `Describe` generic types with conditional
I cannot figure out how to describe generics with conditionals. This might be a limitation by TypeScript though.
Given the following types:
enum Types {
One = "one",
Two = "two",
}
type Validating<T extends Types = Types> = {
type: T;
value: T extends Types.One ? string : never;
};
I can't figure out how to type value
in Validating
. This is my general idea:
const Validating: Describe<Validating> = union([
object({
type: literal(Types.One),
value: string(),
}),
object({
type: literal(Types.Two),
}),
]);
assert({ type: Types.One, value: "Value!" }, Validating);
assert({ type: Types.Two }, Validating);
But this does not compile, giving the following error:
Type 'Struct<{ type: Types.One; value: string; } | { type: Types.Two; }, null>' is not assignable to type 'Describe<Validating<Types>>'.
Types of property 'TYPE' are incompatible.
Type '{ type: Types.One; value: string; } | { type: Types.Two; }' is not assignable to type 'Validating<Types>'.
Type '{ type: Types.Two; }' is not assignable to type 'Validating<Types>'.
I also had a second idea which is even more broken, but is nicer if possible.
const Validating: Describe<Validating> = dynamic((v: any) =>
object({
type: literal(Types.One),
value: v.type === Types.One ? string() : never(),
}),
);
assert({ type: Types.One, value: "Value!" }, Validating);
assert({ type: Types.Two }, Validating);
I think this is related to #724, but not quite the same.
I think I narrowed down the main (two) issue(s).
type Validating<T extends Types = Types> = {
type: T;
value: T extends Types.One ? string : never;
};
const Validating: Describe<Validating> = object({
type: enums(Object.values(Types)),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: dynamic((_, ctx): any => {
const type: Types = ctx.branch.at(-2)?.type ?? Types.One;
if (type === Types.One) {
return string();
} else {
return literal(undefined);
}
}),
});
This works, but requires you to set any
on the return type of the dynamic.
The second issue is when you make the value an array
of string
s, not a string
.
type Validating<T extends Types = Types> = {
type: T;
value: T extends Types.One ? string[] : never;
};
const Validating: Describe<Validating> = object({
type: enums(Object.values(Types)),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: dynamic((_, ctx): any => {
const type: Types = ctx.branch.at(-2)?.type ?? Types.One;
if (type === Types.One) {
return array(string());
} else {
return literal(undefined);
}
}),
});
This Gives you a cryptic type error, but this can be fixed by manually overriding the value type in the object like so:
const Validating: Describe<Validating> = object({
type: enums(Object.values(Types)),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: dynamic((_, ctx): any => {
const type: Types = ctx.branch.at(-2)?.type ?? Types.One;
if (type === Types.One) {
return array(string());
} else {
return literal(undefined);
}
}) as unknown as Describe<Validating["value"]>,
});
Still don't really have a nice fix for this, but hopefully this helps someone else facing the same issue.
Could use Discriminated Union instead
export function discriminatorMapping<
D extends string,
Mapping extends Record<string, TypeAny>
>(
discriminator: D,
mapping: Mapping
): Struct<Simplify<DiscriminatedUnion<D, Mapping>>, null> {
return dynamic<any>((v: any = {}) => {
const discriminatorValue = (v as any)[discriminator];
const matched = mapping[discriminatorValue];
if (typeof (v as any)[discriminator] === "undefined" || !matched) {
return object({
[discriminator]: enums(Object.keys(mapping))
});
}
return object({
[discriminator]: literal(discriminatorValue),
...matched.schema
});
});
}
type DiscriminatedUnion<
D extends string,
Mapping extends Record<string, TypeAny>
> = ValueOf<{
[K in keyof Mapping]: { [k in D]: K } & Infer<Mapping[K]>;
}>;
type ValueOf<T> = T[keyof T];
enum NetType {
AIRGAP = "AIRGAP",
DIRECT = "DIRECT",
}
const t: Struct<{ netType: NetType.AIRGAP } | { netType: NetType.DIRECT, endpoint: string }> = discriminatorMapping("netType", {
[NetType.AIRGAP]: object(),
[NetType.DIRECT]: object({
endpoint: string()
})
}
)