nvie/decoders

Type is not inferred correctly from a dynamically created decoder with generics

vjrasane opened this issue · 4 comments

A bit cryptic description, I know, but maybe the following example clarifies the issue:

import { array, Decoder, object } from "decoders"

type GenericArrayContainer<F> = {
    value: F[]
}

// inferred type matches declared type
const genericArrayContainer = <F>(decoder: Decoder<F>): Decoder<GenericArrayContainer<F>> => object({
    value: array(decoder),
})

type GenericValueContainer<F> = {
    value: F
}

// inferred type does not match declared type
const genericValueContainer = <F>(decoder: Decoder<F>): Decoder<GenericValueContainer<F>> => object({
    value: decoder,
})

as commented, in the first case the type is inferred correctly when the generic value is wrapped in another decoder, but if the field is declared with the dynamically passed decoder directly, type inference no longer works.

This was tried with:

    "decoders": "^2.0.1",
    "typescript": "^4.9.4"

Let me know if there is another approach to achieving what I'm trying to do here or if any further info is required.

nvie commented

Hmm, looks like you may have found a bug. I would expect your example to work correctly, indeed. Does your example work correctly at runtime? This may be a bug in the signature type for object().

I'll have to take a deeper look at this issue later. Might be after the holidays before I have the proper time for it, but I'll try to look at it sooner! Thanks for reporting this!

nvie commented

Just wanted to let you know that I've started to look into this, but a fix isn't trivial, and I need a bit more time to come up with a good solution for this problem (and also one that would work whether you have strictNullChecks enabled or disabled in your project).

The problem has to do with the fact that the F generic type could potentially be undefined, and there is some special treatment to undefined values within the object() decoder: if undefined is accepted by one of the field's decoders, the object() decoder will strip it from the output object. Since you cannot know if the provided decoder to your function will accept undefined values, the output type for it can potentially be either of:

{ value: F }

or

{ value?: F }

This is why TypeScript (correctly) fails. Unfortunately, it leads to a cryptic error message — totally unreadable!

To illustrate the issue further, here you can see the actual runtime behavior of the object() decoder at runtime if you would pass in an optional(string) as the value for your decoder:
https://runkit.com/nvie/63b2bc25b8699f0008779899

Therefore, you could fix this by changing the GenericValueContainer type definition to:

type GenericValueContainer<F> = {
    value?: F
//       ^ Note the question mark
}

This will make the error go away.

However, I realize this may not be ideal for your use case. To properly fix this, it may require the addition of a new decoder in the object() family of decoders (or some kind of "mode") that would not remove undefined keys from the object, and instead keep explicit-undefined keys around.

Ah yes that seems to be the issue. I missed the AllowImplicit in ObjectDecoderType while reading the typings. Fixing it might indeed be tricky if the implicit and explicit undefineds should be both allowed. In most cases these should be interchangeable, as far as I'm aware, but unfortunately in 'key' in obj they checks do behave differently so perhaps it's best to preserve the object decoder as it is. Instead maybe it would make sense to add a explicit function, that takes an object decoder (any of the object/exact/inexact) and returns a decoder that only accepts explicit undefined values in any of it's defined fields, although I'm not sure whether this is technically possible with the current implementation.

I hit into this issue too and I understand how difficult it is to be resolved by the maintainers.

Here's a workaround for now:

import * as JD from "decoders"

type GenericValueContainer<F> = {
  value: F
}

function genericValueContainerDecoder<F>(
  decoder: JD.Decoder<F>,
): JD.Decoder<GenericValueContainer<F>> {
  return JD.define((blob, ok, err) => {
    // Decode the wrapped field as an unknown first
    const result = JD.object({
      value: JD.unknown,
    }).decode(blob)

    if (result.ok === false) {
      return err(result.error)
    }

    // Decode the wrapped value manually
    const { value } = result.value
    const valueF = decoder.decode(value)
    return valueF.ok ? ok({ value: valueF.value }) : err(valueF.error)
  })
}