2

I wrote a fairly straightforward mapped-types based code, which doesn't want to type check for some reason.

First, define input and output:

interface Validated<T> {
  valid: boolean;
  value: T;
} 

interface FieldInputs {
  name: string;
  price: number;
}

interface ParsedFields {
  name: Validated<string>;
  price: Validated<number>;
}

Define parser types and parser map:

type FieldKey = keyof FieldInputs & keyof ParsedFields;
type FieldParser<F extends FieldKey> = (value?: FieldInputs[F]) => ParsedFields[F];
type FieldParsers = {
  [F in FieldKey]: FieldParser<F>;
};

declare let fieldParsers: FieldParsers;

Now this very simple generic function fails to type-check:

function update<F extends FieldKey>(field: F, value: FieldInputs[F]) {
  const parser: FieldParser<F> = fieldParsers[field];
  parser.apply(value);
}

gives the following error (--strictFunctionTypes):

Type 'FieldParsers[F]' is not assignable to type 'FieldParser<F>'.
  Type 'FieldParser<"name"> | FieldParser<"price">' is not assignable to type 'FieldParser<F>'.
    Type 'FieldParser<"name">' is not assignable to type 'FieldParser<F>'.
      Types of parameters 'value' and 'value' are incompatible.
        Type 'FieldInputs[F]' is not assignable to type 'string'.
          Type 'string | number' is not assignable to type 'string'.
            Type 'number' is not assignable to type 'string'.

What am I missing?

Playground Link

mikea
  • 805
  • 6
  • 17
  • There is no error in playground – Niladri May 30 '18 at 18:04
  • 1
    @Niladri you need to enable `--strictFunctionTypes` - it seems that playground options are not shared in the link. – mikea May 30 '18 at 18:06
  • it's because it does not allow bivariant assignation , where you are assigning an union type "string"|number to `fieldParsers[field]` which is of type string only. You can only assign base/super type to derived type with this case .i.e `number = string` is not allowed – Niladri May 30 '18 at 18:23
  • check this link here https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html .. – Niladri May 30 '18 at 18:25
  • @Niladri I am not assigning anything to `fieldParsers[field]`. I am _reading_ its value. – mikea May 30 '18 at 18:29

2 Answers2

2

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!

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • You are correct that problems come from the fact that compiler imagines `F` to be union type. In your first sample `F="name"|"price"`. "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." Do you think this smartness has a name? Should I file a bug? – mikea May 30 '18 at 18:31
  • I don't know... it's not a bug exactly, just a limitation of the kinds of type analysis the compiler can do. As for the name, not sure. I think I might have seen similar issues filed before but my searches are not bringing anything up. – jcalz May 30 '18 at 18:43
0

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
casieber
  • 7,264
  • 2
  • 20
  • 34
  • This `type FieldParser = (value?: T) => Validated;` doesn't work for me. In reality my input/output values differ. (E.g. input can be a string, output - parsed phone number). Yes, by making this critical simplification you will make the code simpler and correct, but this is not what I wont. I want to use two interfaces (Input & Output) to type-check function implementation. – mikea May 30 '18 at 18:26
  • Ah, I see. So just because `price` in your `FieldInputs` interface is `number`, you may want the type of that field once validated to be like `Validated`? – casieber May 30 '18 at 18:32
  • Yes. I have lots of such interface pairs with different sets of parsers. And I’m writing some generic code for working with such sets (combination, aggregation) – mikea May 30 '18 at 18:45