0

I have implemented a type which takes an object and returns a type without any nullish values.

export type OmitNullish<T> = Exclude<T, null | undefined>;
export type OmitNullishKeys<T> = {
    [K in keyof T]-?: T[K] extends boolean | string | number | symbol ? OmitNullish<T[K]> : OmitNullishKeys<T[K]>;
};

However, when I attempt to retrieve a nested key, tsc using:

export type RandomObj = OmitNullishKeys<{
    stackOverflow: {
        forums?: {
            thread1: 'not available';
        } | null;
    } | null;
}>;
export type RandomObjectAccessed = RandomObj['stackOverflow']['forums'];

it states the following:

Property 'forums' does not exist on type 'OmitNullishKeys<{ forums?: { thread1: "not available"; } | null | undefined; } | null>'.

It seems as the resulting type is treated as OmitNullishKeys instead of an object without nullish values. Is there a reason for this?

Urmzd
  • 682
  • 4
  • 17
  • It seems as the problem lied in with lacking the exclusion of `null` and `undefined` for the object itself. `OmitNullish` had to be applied to both sides of the conditional. – Urmzd Nov 17 '21 at 22:23
  • Another issue seemed to be the missed traversal of `optional` keys which do not exist when using `keyof`. `[K in keyof T]` must become `[K in keyof Required]` – Urmzd Nov 18 '21 at 13:49
  • 1
    My guess is you want [this implementation](//tsplay.dev/NlxElN) and your version doesn't work because `OmitNullishKeys` is a [homomorphic mapped type](https://stackoverflow.com/a/59791889/2887218), which are [distributive over unions](https://stackoverflow.com/a/69993188/2887218), so `OmitNullishKeys` will always give you `OmitNullishKeys | OmitNullishKeys` and the latter is `null` because homomorphic mapped types on primitives just yield those primitives. If you want to stop this from happening you need quite a refactoring, which is why I suggested the version above. – jcalz Nov 18 '21 at 14:12
  • So... does *that* count as an answer to your question? That is, the explanation involving homomorphic mapped types, along with links to corroborating documentation and github issues? – jcalz Nov 18 '21 at 14:14
  • @jcalz Yes, that solution suffices. +1 for making it more succinct than my current solution – Urmzd Nov 18 '21 at 15:51

2 Answers2

2

If we introduce a helper type for expanding the type of RandomObj. Then we can clearly see that your logic in OmitNullishKeys is faulty. The resulting type RandomObj does still contain values that might be null.

type ExpandType<T> = T extends object
  ? T extends infer O ? { [K in keyof O]: ExpandType<O[K]> } : never
  : T;

type ExpandedRandomObj = ExpandType<RandomObj>;
/*
type ExpandedRandomObj = {
    stackOverflow: {
        forums: {
            thread1: 'not available';
        } | null;
    } | null;
}
*/

TS playground

Olian04
  • 6,480
  • 2
  • 27
  • 54
  • While this didn't answer the question, this is extremely useful. +1 for solving future me's potential problems. – Urmzd Nov 17 '21 at 22:31
  • Okey @Urmzd. The reason "the resulting type is treated as `OmitNullishKeys`" is because that is what you told TS that it should be. The reason you're getting a type error on the other hand is because your logic in `OmitNullishKeys` is faulty. Which is what i tried to show in my answer. – Olian04 Nov 17 '21 at 22:34
  • 1
    How does this *not* answer the question? The only question I see here is "Is there a reason for this?" and this answer addresses it directly. If the question is supposed to be asking "how can I write `OmitNullishKeys` correctly", that's an understandable thing to want, but you haven't asked it. Right now I wouldn't want to step on this current answer because it does answer the question as asked. – jcalz Nov 18 '21 at 01:43
  • @jcalz This post (while useful), does not demonstrate the "why" involved in the question. I'm not asking how the "correct" type is to be written, I'm asking for the reason why it breaks. It's obvious that there's fault with the code, otherwise it wouldn't be broken (unless there's a bug or missing feature with `typescript`, which is unlikely). It's also apparent there exists `null` values (maybe I could've been more explicit about this). The "answer" doesn't explain how the code is broken, just that it is. – Urmzd Nov 18 '21 at 02:37
  • 1
    Ah, okay. Would you accept an answer like this? "it doesn't remove keys because `{[K in keyof T]-?: XXX}` will always have exactly the keys of `T`, as mapped types do not modify keys (unless you start using [key remapping](https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as)). – jcalz Nov 18 '21 at 02:46
  • @jcalz That provides the reason for a secondary problem (which was the lack of `Required`). Didn't know that was an alternative option, thanks :). However, it doesn't provide a reason for the primary culprit (which is that objects with possible `null` values are still left as `null` due to the lack of an `Exclude`). +1 for the alternative tip – Urmzd Nov 18 '21 at 13:54
  • I must be hopelessly confused here; could you spell out exactly what type you expect `RandomObj` to be and then I can try to discover why it's not what you think it should be? Right now I'm not sure what "a type without any nullish values" means; does it mean you suppress whole properties like `{a: string | undefined}` becomes `{}`, or do you mean that it strips nullishness like `{a: string}`? – jcalz Nov 18 '21 at 13:59
  • @jcalz It doesn't recurse on objects which is the primary problem. I have already discovered the solution (I haven't accepted an answer because there hasn't been an answer which indicated the reasons for the problem). – Urmzd Nov 18 '21 at 15:00
0

The issue was resolved initially by:

export type N = null | undefined;
export type OmitNullish<T> = T extends (infer U)[] ? Exclude<U, N>[] : Exclude<U,N>
export type OmitNullishKeys<T> = {
  [key in keyof Required<T>]-?: T extends Record<string, unknown> ? OmitNullish<OmitNullishKeys<T[K]>> : OmitNullish<T[K]>
}

Which resolved the issue

  1. The exclusion of nullish values were not applied to objects due to the lack of OmitNullish
  2. When keyof is used without enforcing non-optionality, the resulting value is T | undefined. Required forces the key to be treated as it were there.

but applied the non-nullish rules to classes such as Date.

Using the alternate solution provided by @jcalz with the addition of a conditional to avoid overwriting classes, we get

type OmitNullish<T, E = Date> = T extends E ? T : NonNullable<{ 
  [key in keyof T]-?: OmitNullish<T[K]>
}>

which gives us a succinct way of ensuring all elements within an object are not-nullish.

Urmzd
  • 682
  • 4
  • 17