superstruct icon indicating copy to clipboard operation
superstruct copied to clipboard

Impossible to `Describe` generic types with conditional

Open dsluijk opened this issue 2 years ago • 2 comments

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.

dsluijk avatar Dec 28 '22 22:12 dsluijk

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 strings, 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.

dsluijk avatar Dec 29 '22 15:12 dsluijk

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()
    })
  }
)

morlay avatar Apr 24 '23 06:04 morlay