Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Assigning/merging objects recursively while leaving arrays intact #136

Closed
kripod opened this issue Aug 2, 2020 · 18 comments
Closed

Assigning/merging objects recursively while leaving arrays intact #136

kripod opened this issue Aug 2, 2020 · 18 comments
Assignees
Labels
feature Add new features

Comments

@kripod
Copy link

kripod commented Aug 2, 2020

🤔 Question

Describe your question

I'm currently working on a deepAssign function which calls Object.assign recursively on object literals (but not arrays or instantiated classes like Date). The header looks as follows:

function deepAssign<
  T extends { [key: string]: any },
  U extends Array<{ [key: string]: any }>
>(target: T, ...sources: U): O.Assign<T, U, "deep">;

I would like the following object:

deepAssign(
  { arr: [1, 2, 3] as const },
  { arr: [4] as const, b: null },
  { b: "test", c: { d: new Date() } },
  { c: { d: { e: "Not merged with date properties" } } },
);

To match the following type:

type T = {
  arr: [4], // Not [4, 2, 3]
  b: string,
  { c: { d: { e: string } },
};

How should this be done?

Search tags, topics

#deepAssign #deepMerge #object

@millsp
Copy link
Owner

millsp commented Aug 2, 2020 via email

@kripod
Copy link
Author

kripod commented Aug 2, 2020

Thank you, I’m eagerly waiting for how this will turn out. As for the arrays, I’m going to treat them like tuples, which are immutable by nature. My implementation of deepAssign may possibly be used for storing CSS styling rules in JS. In that case, arrays are utilized for providing fallback values. Assigning them deeply wouldn’t make sense.

@millsp
Copy link
Owner

millsp commented Aug 3, 2020

Hey @kripod, there's just a few problems that I see right now. Let's say that the ...sources are passed like ...[{a: 1}], then typescript will infer U as {a: 1}[], producing a unionized array instead (type widening). To avoid this you will have to pass ...[[a: 1}] as const and then U will be inferred as [{a: 1}]. # is essentially as const in TypeScript.

I have a tool Union.Merge that is able to apply merging to unions. So I suggest that if as const is not passed, then U.Merge will be used, otherwise, the precise implementation will be used. I guess this is something you will have to mention for TypeScript users, until # gets implemented. It is not really a problem, so to speak.

But there is this, unfortunately, there is no way to distinguish between an object created via a class or an object literal. This issue microsoft/TypeScript#3841 is our best hope atm. Here's a summary of what I've concluded after trying to do this with infer:

class X {
    x() {}
}

type IsClass<A extends any> =
    A extends new (...args: any[]) => any
    ? 1
    : 0

const x = new X()           // create x

type t0 = IsClass<X>        // works: nope. X is the type yielded by calling new on X
type t1 = IsClass<typeof X> // works: yes.  typeof X is the type of the class itself

const t1: X        = new X() // proves what we've said above
const t2: typeof X = X       // proves what we've said above

declare function isClass<O>(o: O): IsClass<typeof O>
// if `O` is of type `X` it should yield `1`

const t = isClass(x) // does not work :( 

@millsp millsp added the question More info is requested label Aug 3, 2020
@kripod
Copy link
Author

kripod commented Aug 3, 2020

Thank you for these ideas! I'm not quite sure whether the 'with ...[{a: 1}], U will be inferred as {a: 1}[]' constraint stands still with TypeScript 4. As for the IsClass type, it's less important than the immutability of arrays. The latter would be crucial, though.

@millsp
Copy link
Owner

millsp commented Aug 3, 2020

@kripod do you still want me to build this for you, regardless of the problems with the class instances? Or maybe I just misunderstood when you said classes, did you mean built-in objects must not be merged, but merge object literals and regular class instances?

@kripod
Copy link
Author

kripod commented Aug 3, 2020

I would highly appreciate if you could come up with a solution to this, even an imperfect one would suffice 😊

For the initial version of the package I'm working on, merging types of regular type instances is a quirk I could live with. (I saw something in the issue you've linked which might be interesting.)

Also, I'll make sure to add you as a contributor once I'll have the chance to release the small utility library.

@millsp
Copy link
Owner

millsp commented Aug 3, 2020

Mhh, I just tested it, and it does not seem to work. This is very frustrating, you're not the first one to ask. Ok, great! So I'll work on this very soon for you. Will ping you when it's done 👍

@millsp
Copy link
Owner

millsp commented Aug 4, 2020

Hey Kristof, I think that it's done. Just let me know if it works for you! You must just install the ts-toolbelt and the following imports will work. Feel free to reformat to your taste.

import { Cast } from "Any/Cast"
import { Extends } from "Any/Extends"
import { Key } from "Any/Key"
import { Iteration } from "Iteration/Iteration"
import { IterationOf } from "Iteration/IterationOf"
import { Next } from "Iteration/Next"
import { Pos } from "Iteration/Pos"
import { Length } from "List/Length"
import { List } from "List/List"
import { BuiltInObject } from "Misc/BuiltInObject"
import { AtBasic } from "Object/At"
import { _OptionalKeys } from "Object/OptionalKeys"
import { Anyfy, Depth } from "Object/_Internal"

type MergeProp<OK, O1K, K extends Key, OOK extends Key> =
    [K] extends [never]
    ? [OK] extends [never]
      ? O1K
      : OK
    : K extends OOK                            // if prop of `O` is optional
      ? NonNullable<OK> | O1K                  // merge it with prop of `O1`
      : [OK] extends [never]                   // if it does not exist
        ? O1K                                  // complete with prop of `O1`
        : OK

type __MergeFlat<O extends object, O1 extends object, OOK extends Key = _OptionalKeys<O>> = {
    [K in keyof (Anyfy<O> & O1)]: MergeProp<AtBasic<O, K>, AtBasic<O1, K>, K, OOK>
} & {}

type _MergeFlat<O extends object, O1 extends object> =
    __MergeFlat<O, O1>

type MergeFlat<O extends object, O1 extends object> =
    O extends unknown
    ? O1 extends unknown
      ? _MergeFlat<O, O1>
      : never
    : never

type __MergeDeep<O extends object, O1 extends object, OOK extends Key = _OptionalKeys<O>> = {
    [K in keyof (Anyfy<O> & O1)]: _MergeDeep<AtBasic<O, K>, AtBasic<O1, K>, K, OOK>
} & {}

type ChooseMergeDeep<OK, O1K, K extends Key, OOK extends Key> =
    OK extends BuiltInObject | List
    ? MergeProp<OK, O1K, K, OOK>
    : O1K extends BuiltInObject | List
      ? MergeProp<OK, O1K, K, OOK>
      : OK extends object
        ? O1K extends object
          ? __MergeDeep<OK, O1K>
          : MergeProp<OK, O1K, K, OOK>
        : MergeProp<OK, O1K, K, OOK>

type _MergeDeep<O, O1, K extends Key, OOK extends Key> =
    [O] extends [never]
    ? MergeProp<O, O1, K, OOK>
    : [O1] extends [never]
      ? MergeProp<O, O1, K, OOK>
      : ChooseMergeDeep<O, O1, K, OOK>

type MergeDeep<O, O1> =
    O extends unknown
    ? O1 extends unknown
      ? _MergeDeep<O, O1, never, never>
      : never
    : never

type Merge<O extends object, O1 extends object, depth extends Depth = 'flat'> = {
    'flat': MergeFlat<O, O1>
    'deep': MergeDeep<O, O1>
}[depth]

type __Assign<O extends object, Os extends List<object>, depth extends Depth, I extends Iteration = IterationOf<'0'>> = {
    0: __Assign<Merge<Os[Pos<I>], O, depth>, Os, depth, Next<I>>
    1: O
}[Extends<Pos<I>, Length<Os>>]

type _Assign<O extends object, Os extends List<object>, depth extends Depth> =
    __Assign<O, Os, depth> extends infer X
    ? Cast<X, object>
    : never

export type Assign<O extends object, Os extends List<object>, depth extends Depth = 'deep'> =
    O extends unknown
    ? Os extends unknown
      ? _Assign<O, Os, depth>
      : never
    : never

/**
 * Mini spec
 */
type t0 = Assign<{}, [{a: {b: {c: 1}}}, {a: {b: {c: 2, d: 3}}}]>
type t1 = Assign<{}, [{a: {b: {c: 1}}}, {a: {b: {c: undefined, d: 3}}}]>
type t2 = Assign<{}, [{a: {b: {c: 1}}}, {a: {b: {c?: 2, d: 3}}}]>
type t3 = Assign<{}, [{a: {b: {c?: 1}}}, {a: {b: {c?: 2, d: 3}}}]>
type t4 = Assign<{}, [{a: {b: {c: 1}}}, {a: {b: {c?: 2, d: 3}}}]>
type t5 = Assign<{}, [{a: [1, 2, 3]}, {a: [1]}]>
type t6 = Assign<{}, [[], {a: 1}]>

@millsp millsp closed this as completed Aug 4, 2020
@millsp
Copy link
Owner

millsp commented Aug 4, 2020

@kripod, is this what you wanted?

@kripod
Copy link
Author

kripod commented Aug 5, 2020

Yes, that seems to do it, thank you!

It would be neat is there was a more convenient built-in method specifically for this. For instance, adding a new MergeStyle parameter type might help to avoid merging arrays when using Assign. I would appreciate if this could be done, as your complex customized solution may become hard to maintain over time.

@millsp
Copy link
Owner

millsp commented Aug 5, 2020

complex customized solution

Are you sure it's that complex? 🤣 No worries, I understand that you don't want to maintain such a thing. It's a good idea to keep generic, thanks. Instead, I think I could add a parameter noMerge so anyone can specify types that must not get merged. That way, we can keep it generic and you'll benefit from eventual bug fixes in the future.

So I'm marking this as a feature request!

@millsp millsp reopened this Aug 5, 2020
@millsp millsp added feature Add new features and removed question More info is requested labels Aug 5, 2020
@millsp millsp self-assigned this Aug 5, 2020
@millsp
Copy link
Owner

millsp commented Aug 5, 2020

It's done! So you will use ramda's merging style (1) and will disallow merging of any built-in objects or lists (BuiltInObject | List):

import {L, O, M} from 'ts-toolbelt'

type DeepAssign<O extends object, Os extends L.List<object>> =
    O.Assign<O, Os, 'deep', 1, M.BuiltInObject | L.List>

/**
 * Mini spec
 */
type t0 = DeepAssign<{}, [{a: {b: {c: 1}}}, {a: {b: {c: 2, d: 3}}}]>
type t1 = DeepAssign<{}, [{a: {b: {c: 1}}}, {a: {b: {c: undefined, d: 3}}}]>
type t2 = DeepAssign<{}, [{a: {b: {c: 1}}}, {a: {b: {c?: 2, d: 3}}}]>
type t3 = DeepAssign<{}, [{a: {b: {c?: 1}}}, {a: {b: {c?: 2, d: 3}}}]>
type t4 = DeepAssign<{}, [{a: {b: {c: 1}}}, {a: {b: {c?: 2, d: 3}}}]>
type t5 = DeepAssign<{}, [{a: [1, 2, 3]}, {a: [1]}]>
type t6 = DeepAssign<{}, [[], {a: 1}]>

@millsp millsp closed this as completed Aug 5, 2020
@kripod
Copy link
Author

kripod commented Aug 5, 2020

Marvelous, thank you so much for creating this in such a short time! 🙏

@kripod
Copy link
Author

kripod commented Aug 7, 2020

Unfortunately, it turns out that O.Assign discards keys of the first objects, as seen below:

import { O, M, L } from "ts-toolbelt";

export default function deepAssign<
  T extends { [key: string]: any },
  U extends Array<{ [key: string]: any }>
>(
  target: T,
  ...sources: U
): O.Assign<T, U, "deep", 1, M.BuiltInObject | L.List>;

export default function deepAssign(target: Record<string, any>) {
  const sources = Array.prototype.slice.call(arguments, 1);
  sources.forEach((source) => {
    for (const key in source) {
      if (Object.prototype.hasOwnProperty.call(source, key)) {
        const value = source[key];
        typeof value === "object" /* TODO: Check for "record", too */ &&
        value /* !== null */ &&
        Object instanceof (value.constructor || /* Returns false: */ deepAssign)
          ? deepAssign(target[key], value) // Extend object literals only
          : (target[key] = value); // Treat non-literals like `Date` atomically
      }
    }
  });
  return target;
}

const x0 = deepAssign({}, { a: { b: { c: 1 } } }, { a: { b: { c: 2, d: 3 } } });
const x1 = deepAssign(
  {},
  { a: { b: { c: 1 } } },
  { a: { b: { c: undefined, d: 3 } } }
);
const x5 = deepAssign({}, { a: [1, 2, 3] as const }, { a: [1] as const });
const x5b = deepAssign(
  {},
  { a: [1, 2, 3] as const, b: "This string gets discarded" },
  { a: [1] as const }
);
const x6 = deepAssign({}, [], { a: 1 });

const x9 = deepAssign(
  { arr: [1, 2, 3] as const },
  { arr: [4] as const, b: null },
  { b: "test", c: { d: new Date() } },
  { c: { d: { e: "Not merged with date properties" } } }
);

x5b has a type of { a: readonly [1]; } instead of { a: readonly [1]; b: string; }.
x9 seems to infer its return type only from the last parameter.

@millsp
Copy link
Owner

millsp commented Aug 9, 2020

Will look into this soon! Thanks for reporting 👍 Easy fix

@millsp millsp reopened this Aug 9, 2020
@millsp
Copy link
Owner

millsp commented Aug 9, 2020

I cannot reproduce the bug

type Os = [
  {},
  { a: [1, 2, 3], b: 'This string gets discarded' },
  { a: [1] }
]

type t = O.Assign<{}, Os, 'deep', 1, M.BuiltInObject | L.List>

Screenshot from 2020-08-09 12-45-14
Screenshot from 2020-08-09 12-45-00

@kripod
Copy link
Author

kripod commented Aug 9, 2020

That's weird. As I've reinstalled my dependencies, I cannot reproduce it, either. Sorry for the false positive and thank you for being so helpful again! 😊

@kripod kripod closed this as completed Aug 9, 2020
@millsp
Copy link
Owner

millsp commented Aug 9, 2020

Sometimes it happens that ts gets "buggy" while editing complex types, as it tries to evaluate the type before it's even complete (eg. GenericType<A, [). I haven't seen much of this behavior on 4.0 though. So maybe it could be just that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Add new features
Projects
None yet
Development

No branches or pull requests

2 participants