1

I'm trying to figure out how to eliminate some duplicate code when handling a discriminated union in a way that does not weaken the type checking:

const handlers = {
  foo: (input: string): void => console.log(`foo ${input}`),
  bar: (input: number): void => console.log(`bar ${input}`),
};

type Handlers = typeof handlers;
type Values<T> = T[keyof T];
type DiscriminatedInput = Values<{
  [Id in keyof Handlers]: {type: Id; value: Parameters<Handlers[Id]>[0]};
}>;

const inputs: DiscriminatedInput[] = [
  JSON.parse('{"type": "foo", "value": "hello world"}'),
  JSON.parse('{"type": "bar", "value": 42}'),
];
for (const input of inputs) {
  // This doesn't work:
  //
  //     handlers[input.type](input.value);
  //                          ^^^^^^^^^^^
  //         error: Argument of type 'string | number' is not assignable to
  //         parameter of type 'never'.
  //
  // So I do this instead, which has a lot of duplication and must be kept in
  // sync with `handlers` above:
  switch (input.type) {
    case 'foo': handlers[input.type](input.value); break;
    case 'bar': handlers[input.type](input.value); break;
    default: ((exhaustiveCheck: never) => {})(input);
  }
}

Inside the for loop above, handlers[input.type] is guaranteed to be a function whose first parameter always matches the type of input.value, regardless of input.type. It seems to me that TypeScript should be able to see that, but it doesn't.

Am I doing something wrong, or is this is a limitation of TypeScript?

If it's a limitation of TypeScript, is there an existing bug report? Is there something I can do to help TypeScript narrow input to a foo- or bar-specific type so that I can eliminate that switch statement? Or refactor DisciminatedInput?

I could use a type assertion to weaken the type checking, but that adds complexity and reduces readability just to work around a language limitation. I'd rather work with the language instead of against it.

Richard Hansen
  • 51,690
  • 20
  • 90
  • 97
  • It is possible to do only with type assertion. Union of functions is not very useful. You can check my [article](https://catchts.com/react-props#first) with explanation and [this](https://stackoverflow.com/questions/68966710/how-to-get-concrete-type-from-mapping-variable/68967097#68967097) answer – captain-yossarian from Ukraine Feb 12 '22 at 08:38
  • If you are really using `JSON.parse` to get the data in the inputs, then the type safety is asserted anyway, so go ahead and use an assertion if you know you can rely on the validity of what's being parsed. There's not much value in using a discriminated union in place of the `any`s since you are relying on untyped data (from outside your compilation graph). – jsejcksn Feb 12 '22 at 08:56
  • Does [this](https://tsplay.dev/NabLnm) meet your needs? – jsejcksn Feb 12 '22 at 09:08
  • @captain-yossarian and @jsejcksn thanks for your comments. Both seem to describe ways to work around limitations in TypeScript's typing by weakening the types. Is that correct? It seems like TypeScript should be able to determine that the first parameter to `handlers[input.type]` always matches the type of `input.value` regardless of `input.type`. If this is just a limitation in TypeScript then that's an acceptable answer. – Richard Hansen Feb 12 '22 at 21:00

1 Answers1

1

This answer is in response to your latest question revision and your comment:

You can view an assertion as widening (weakening) types, but what's really happening is that you are artificially narrowing (strengthening) the types by asserting what gets parsed from the JSON strings (which is actually any), and then having to fight against what you asserted to the compiler.

If you:

  • don't want to use a type assertion, and
  • must parse JSON inputs according to the schema provided in your question

then you can refactor your handlers to include the typeof runtime value of each handler's input parameter. This will enable you to then validate that there is a correlation between it and the typeof the input value parsed from each JSON object: using a type predicate function to satisfy the compiler.

In summary: this replaces the discussed assertion with a predicate, which uses a runtime check to enforce simple validation of the parsed JSON input before invoking its associated handler.

Here's an example of such a refactor:

TS Playground

type Values<T> = T[keyof T];
type Handler<Param> = (input: Param) => void;

type HandlerData<Param> = {
  inputType: string;
  fn: Handler<Param>;
};

/**
 * This identity function preservees the type details of the provided
 * argument object, while enforcing that it extends the constraint (which
 * is used later in a predicate to ensure a level of type-safety of the parsed JSON)
 */
function createHandlers <T extends Record<string, HandlerData<any>>>(handlers: T): T {
  return handlers;
}

const handlers = createHandlers({
  foo: {
    inputType: 'string',
    fn: (input: string): void => console.log(`foo ${input}`),
  },
  bar: {
    inputType: 'number',
    fn: (input: number): void => console.log(`bar ${input}`),
  },
});

type Handlers = typeof handlers;

type DiscriminatedInput = Values<{
  [Key in keyof Handlers]: {
    type: Key;
    value: Parameters<Handlers[Key]['fn']>[0];
  };
}>;

// This type is required for the usage in the following predicate
type HandlerDataWithInput<Param, Value> = HandlerData<Param> & { value: Value };

function dataIsTypeSafe <T = DiscriminatedInput['value']>(data: HandlerDataWithInput<any, any>): data is HandlerDataWithInput<T, T> {
  return typeof data.value === data.inputType;
}

const inputs: DiscriminatedInput[] = [
  JSON.parse('{"type": "foo", "value": "hello world"}'),
  JSON.parse('{"type": "bar", "value": 42}'),
];

for (const input of inputs) {
  const data = {...handlers[input.type], value: input.value};
  if (dataIsTypeSafe(data)) data.fn(data.value);
}

jsejcksn
  • 27,667
  • 4
  • 38
  • 62
  • Thanks for your answer! I was trying to generalize this to support arbitrary input types (not just primitives), and I think `dataIsTypeSafe` implementation can be replaced with `validate(data.input) && handlers[data.input.type] === data.handler` (where the `validate(data.input)` check can be removed if already validated). Would that work? I'm new to TypeScript so I'm still trying to wrap my head around everything. – Richard Hansen Feb 13 '22 at 21:12
  • 1
    @RichardHansen I don't know what your `validate` function is, but if your inputs have enumerated types (and they're already validated by the source creating the JSON), then you can associate them to unique IDs (like in [JSON Schema](https://json-schema.org/) or [GraphQL](https://graphql.org/)) and just compare the type ID in the JSON to the corresponding type ID in the handler data object inside the predicate implementation. – jsejcksn Feb 13 '22 at 21:23