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.
CodePudding user response:
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:
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);
}