2

In this question on implementing a recursive partial in typescript, we get some answers that look good... except the latest answer points out they are all incomplete.

Let's take a closer look with examples:

These are three of the proposed solutions:

//The simple one
type SolutionA<T> = {
    [P in keyof T]?: SolutionA<T[P]>;
};

//The 'complete' one
type SolutionB<T> = {
  [P in keyof T]?:
    T[P] extends (infer U)[] ? SolutionB<U>[] :
    T[P] extends object ? SolutionB<T[P]> :
    T[P];
};

//The one that accounts for Date
type SolutionC<T> = {
    [P in keyof T]?:
    T[P] extends Array<infer U> ? Array<Value<U>> : Value<T[P]>;
};
type AllowedPrimitives = boolean | string | number | Date /* add any types than should be considered as a value, say, DateTimeOffset */;
type Value<T> = T extends AllowedPrimitives ? T : SolutionC<T>;

And here is the example that shows A and B are incomplete:

type TT = { dateValue: Date }
const x1: SolutionA<TT> = { dateValue: "0" } // counterintuitively allowed
const x2: SolutionB<TT> = { dateValue: "0" } // counterintuitively allowed
const x3: SolutionC<TT> = { dateValue: "0" } // correctly disallowed by ts

Why does this happen? Is there a way to make a recursive partial without having to manually include every single exception like Maps, Sets, etc? What's the common thread between these 'exceptional' types? Should I worry about someone creating their own exception that has to be added to the list?

leinaD_natipaC
  • 4,299
  • 5
  • 21
  • 40
  • 1
    Fairly certain that dates aren't alone. What about Map, Set, and RegExp? – kelsny Aug 22 '22 at 19:15
  • Yeah, what gives? – leinaD_natipaC Aug 22 '22 at 19:16
  • What **exactly** does "error" mean? What happens? – Pointy Aug 22 '22 at 19:16
  • 2
    Dates and friends aren't "objects" in the sense that they are a map of keys to values. They're a value and should be treated like strings, numbers, and booleans. Same with RegExp, yet they aren't considered "literal values" either. You end up with this behavior because of the way JavaScript was designed: dates and regex are inherently objects, but are treated as one value. – kelsny Aug 22 '22 at 19:20
  • If they're not objects, then what the hell are they? – leinaD_natipaC Aug 22 '22 at 19:35
  • 1
    My *guess* here is that you don't want to accept a "partial" of anything with methods (i.e., a function-valued member), except for possibly arrays. So an object containing just primitives or subproperties with primitives, or subsubproperties, etc., would be fine. If so, then maybe [this approach](https://tsplay.dev/w1pP2W) is what you're looking for? Please try it out on your use cases and let me know. If it works for you I could write up an answer; otherwise, what am I missing? – jcalz Aug 22 '22 at 19:57
  • @jcalz your approach works great. Let me see if I understand it: you stop recursion whenever T is not an object (so, a literal), or when it is _possibly_ a class instance. In this case, what I'm calling a class instance you've defined as an object where any of its properties is a function (if I've read correctly, whenever 'generic function' can extend any of T's properties: `((...args: any) => any) extends T[keyof T]`). This should definitely work nearly always. Why do you continue recursion in case T is a `readonly any[]`? I don't understand that at all. – leinaD_natipaC Aug 24 '22 at 15:37
  • @jcalz You should post your approach as an answer in the question I reference, but for my question it's missing an explanation as to why `SolutionA`/`SolutionB` admits passing a string to `dateValue`, which would make it the perfect answer. Any insights? – leinaD_natipaC Aug 24 '22 at 15:49

1 Answers1

0

You would need to handle each native type (i.e. Date, Map, Set, etc.) individually, something like...

type NonAny = number | boolean | string | symbol | null;
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends NonAny[] // checks for nested any[]
    ? T[P]
    : T[P] extends ReadonlyArray<NonAny> // checks for nested ReadonlyArray<any>
    ? T[P]
    : T[P] extends Date // checks for Date
    ? T[P]
    : T[P] extends (infer U)[]
    ? DeepPartial<U>[]
    : T[P] extends ReadonlyArray<infer U>
    ? ReadonlyArray<DeepPartial<U>>
    : T[P] extends Set<infer V> // checks for Sets
    ? Set<DeepPartial<V>>
    : T[P] extends Map<infer K, infer V> // checks for Maps
    ? Map<K, DeepPartial<V>>
    : T[P] extends NonAny // checks for primative values
    ? T[P]
    : DeepPartial<T[P]>; // recurse for all non-array, non-date and non-primative values
};

Then your type enforcement would be correct. See TS playground.

type TT = { dateValue: Date }
const x1: DeepPartial<TT> = { dateValue: "0" } // correctly disallowed by ts

x1.dateValue?.getDate() // allowed method invocation

See related post How to implement TypeScript deep partial mapped type not breaking array properties

See TS playground troubleshooting Data as string condition here

Nickofthyme
  • 3,032
  • 23
  • 40
  • How do I know what the complete set of native nonliteral types are? Why does (in my case `Date`) behave differently than a normally defined object? Why can I assign a string or number or anything to it after applying recursivelypartial to it? – leinaD_natipaC Aug 23 '22 at 08:53
  • It really depends on what types you need. I started the `DeepPartial` type above to only recurse on non-primitive and non-array values (i.e. only objects). But then I wanted a deep partial `Map`, then deep partial `Set`. But in your case you want `Date` but it wouldn't make sense to have a deep partial `Date` so you treat it like a primitive value such as a `number`. So it's hard to say which types you need to be _complete_ because the same type may be handled differently for other use cases. Say for example there are some cases where you want arrays of objects to be partial and some not. – Nickofthyme Aug 23 '22 at 16:36
  • **_> Why does (in my case Date) behave differently than a normally defined object?_** It's because you include the `Date` type in the `AllowedPrimitives` union. The `Value` type you define, says if type `T` a `boolean`, `string`, `number` or `Date` then return the original type `T`, else recurse via `SolutionC. Removing the `Date` type from the `AllowedPrimitives` union will treat the `Date` as any other `Object`. – Nickofthyme Aug 23 '22 at 16:42
  • **_> Why can I assign a string or number or anything to it after applying recursivelypartial to it?_** That's a great question! TLDR -> I don't know . For some reason `SolutionA` and `SolutionB` permit strings where other custom interface types do not. I played around with it a bit on a ts playground, the link was too long so see the bottom of my post. But I tend to reference the utility-types repo for complex types and their [`DeepPartial`](https://github.com/piotrwitek/utility-types/blob/df2502ef504c4ba8bd9de81a45baef112b7921d0/src/mapped-types.ts#L504-L510) type... – Nickofthyme Aug 23 '22 at 17:33
  • ...has a condition to check `T extends Function` which is the only difference. So not sure how to explain that one seems like a deep rabbit hole to avoid . But if you do figure it out please let me know. – Nickofthyme Aug 23 '22 at 17:35