I wrote a fairly straightforward mapped-types based code, which doesn\'t want to type check for some reason.
First, define input and output:
interfac
The compiler is protecting you against something unlikely to happen, and you need to decide how to work around it (spoiler alert: use a type assertion)
Imagine if I do this:
const field = Math.random() < 0.5 ? "name" : "price";
const value = Math.random() < 0.5 ? "Widget" : 9.95;
update(field, value); // no error
In this case, field
is of the type FieldKey
and value
is of the type FieldInputs[FieldKey]
, and there is a 50% chance that they don't match up. Despite this, the compiler does not warn you: it infers that F
is FieldKey
(which is a perfectly valid thing for it to do), and the call to update()
is allowed.
Inside the implementation of update()
, there is the warning that FieldParsers[F]
might not be a FieldParser<F>
. If F
is FieldKey
as above, this mismatch becomes apparent. FieldParsers[F]
will be FieldParser<'name'> | FieldParser<'price'>
, but FieldParser<F>
is FieldParser<'name' | 'price'>
. The former is either something that parses a string
or something that parses a number
. The latter is something that parses either a string
or a number
. These are not the same (due to contravariance of function parameters enabled with --strictFunctionTypes
). The difference between these types is exposed when the code above ends up calling update("name", 9.95)
and you try to parse a number
with a string
parser. You want a FieldParser<F>
, but all you have is a FieldParsers[F]
.
Now backing up, is someone likely to play games like this where F
is a union of values? If so, then you might want to change your definition of update()
to explicitly prohibit F
from being anything but a single string literal. Something like...
type NotAUnion<T, U = T> =
U extends any ? [T] extends [U] ? T : never : never;
declare function update<F extends FieldKey>(
field: F & NotAUnion<F>,
value: FieldInputs[F]
);
But that is probably overkill, and it still doesn't resolve the warning inside the implementation of update()
. The compiler is simply not smart enough to understand that the value of F
is a single string literal value and that what you are doing is safe.
To make that error go away, you will probably want to do a type assertion. Either you know that nobody is likely to intentionally shoot themselves in the foot by widening F
to FieldKey
, or you have prevented the caller from doing this by using something like NotAUnion
. In either case, you can tell the compiler that you know that fieldParsers[field]
will be a valid FieldParser<F>
:
function update<F extends FieldKey>(field, value: FieldInputs[F]) {
const parser = fieldParsers[field] as FieldParser<F>; // okay
parser.apply(value);
}
So that works. Hope that helps. Good luck!
I think you may have gone a little bit overboard with the types. Simplifying them will actually solve your problem.
Let's start by looking at your type definition for FieldParser
:
type FieldParser<F extends FieldKey> = (value?: FieldInputs[F]) => ParsedFields[F];
All it is really doing is accepting a value and returning a Validated
object of the same type. We can simplify this down to:
type FieldParser<T> = (value?: T) => Validated<T>;
That not only improves the complexity, but it also greatly improves the readability of the type.
Note, however, that this does mean we have lost the restriction on FieldParser
that it only can be used with keys from FieldKey
. But in reality, if you think about the generic concept of a "Field Parser", it should be generic, and as we will see in a second, this doesn't mean your consuming code becomes any less strict.
We can also then build FieldParsers
as a generic type
type FieldParsers<T> = {
[K in keyof T]: FieldParser<K>;
}
Then the rest of the code can use those without issue:
interface MyFieldInputs {
name: string;
price: number;
}
declare let fieldParsers: FieldParsers<MyFieldInputs>;
function update<T extends keyof MyFieldInputs>(field: T, value: MyFieldInputs[T]) {
const parser = fieldParsers[field];
parser.apply(value);
}
However, we can do even better. You still have to use parser.apply(value)
here, when really you should be able to simply call parser(value)
.
Let's take the generics one step further, and rather than hardcoding the update
function to make use of the specific fieldParsers
variable that we defined before the function, let's use a function to build the update function.
function buildUpdate<TInputs>(parsers: FieldParsers<TInputs>) {
return function update<T extends keyof TInputs>(field: T, value: TInputs[T]) {
const parser = parsers[field];
parser(value);
}
}
By doing that, we can easily tie together all the types, and Typescript will simply accept (and typecheck) calling parser(value)
.
So now, putting it all together, you end up with:
interface Validated<T> {
valid: boolean;
value: T;
}
/**
* Generic field validator
*/
type FieldParser<T> = (value?: T) => Validated<T>;
/**
* Generic set of field validators for a specific set of field types
*/
type FieldParsers<T> = {
[K in keyof T]: FieldParser<T[K]>
}
function buildUpdate<TInputs>(parsers: FieldParsers<TInputs>) {
return function update<T extends keyof TInputs>(field: T, value: TInputs[T]) {
const parser = parsers[field];
parser(value);
}
}
And you would make use of it by doing:
interface MyFieldInputs {
name: string;
price: number;
}
declare let fieldParsers: FieldParsers<MyFieldInputs>;
const update = buildUpdate(fieldParsers);
update('name', 'new name'); // Fully type checked
update('name', 5); // ERROR