Closed SephReed closed 3 years ago
extends the base generics -- as in DeepUnion<A, B> extends A and extends B is true
This sounds more like an intersection than a union. Union types are never directly assignable to any of their constituent types (without narrowing them first).
I'm not positive on the semantics here. There are some code examples above which show what I'm talking about. Please feel free to change any vocabulary I may have misused or misunderstood.
Also, I never said "directly assignable," I'm speaking more of generics X extends Y
stuff... which may be impossible, but this is only one of many things such a utility is necessary for.
As it stands, my project can not type check in less than 15 seconds because of how gnarly the home-brewed deep merge is.
Hmmm.... I may have found a solution for now:
export type SubpropertyMerge<T> = (
T extends (...args: infer A) => infer R ? (
(...args: A) => R
): (
T extends object ? (
T extends string | number | ((...args: any) => any) | symbol | boolean ? T
: { [K in keyof T]: SubpropertyMerge<T[K]> }
) : T
)
);
By adding the "catch if extending object + string" case, things appear to be working (and type checking within a few seconds).
Is there a way to specifically state T extends object and nothing else
? Assuming this FR isn't going anywhere, it'd be nice to at least share the best possible vanilla form of this functionality.
A & B
is an intersection, not a union. As for assignability: because of the way the TS structural type system works, A extends B
means exactly āA is assignable to Bā.
type Base = { x: number }
function takesBase<T extends Base>(b: T) {}
takesBase<{x: 2}>({x: 2});
Something like this, though it's not the major facet here. Please see the "Use Cases" section. It makes it pretty clear what's confusing.
Please see the "Use Cases" section. It makes it pretty clear what's confusing.
The example in your "Use Cases" section is honestly very confusing. You say when people write A & B
they expect a union, but why would they expect a union when they use the intersection operator? In the screenshot you can see exactly what I would expect: tt.foo
being never
, because the intersection of A
and B
results in an impossible type.
Hmmm... I'm going to try to explain again:
I don't think what I'm talking about is either a union or an intersection. Perhaps I'll call it deep merge. I'm going to change the post to remove any reference to unions.
So for something like the following example, things work great:
type Bear = { hands: { bigAndScary: true }}
type Frog = { hands: { sticky: true }}
const bearFrog: Bear & Frog = null as any;
bearFrog.hands.bigAndScary; // true
bearFrog.hands.sticky; // true
But for something like the following example, things fall apart:
type Jukebox = { accepts: "quarters"; playsMusic: true; }
type Atm = { accepts: "cards"; hasTouchScreen: true; }
const strangeMachine: Jukebox & Atm = null as any;
typeof strangeMachine; // never
const strangeMachine2: Jukebox | Atm = null as any;
strangeMachine2.accepts; // yay! it works
strangeMachine2.playsMusic; // darn... not a prop
strangeMachine2.hasTouchScreen; // not a prop
Rather than strangeMachine
being a machine that does all the things of both its component machines, it does none of them... or just one part of them. I both understand the logic here, and feel it as intuitive. If someone said "give me something thats both a jukebox and atm", I wouldn't think they wanted either of the outcomes of union or intersection. I'd think they wanted whichever one was possible on a property to property basis. Like this:
type StrangeMachine = {
accepts: "quarters" | "cards"; // union here
playsMusic: true; // more of an intersection here
hasTouchScreen: true; // and here
}
I was wrong to say StrangeMachine should extends Jukebox and ATM. It's more that -- with some effort -- you can set things up so that your "Deep Merges" will extend some super generic base, to further define it. Kind of like carving marble. A user can start off with something super open ended, and then deep merge to "narrow" the fields... then deep merge more to open them a bit.
type MarbleStatue = { focus: string; }
type GreekStatue = { focus: "lifelike"; polished: true; }
type AbstractStatue = { focus: "geometric"; verySubjectiveMeaning: true; }
function statueFn<T extends MarbleStatue>(statue: T) { return statue; }
const fusionStatue: DeepMerge<GreekStatue, AbstractStatue> = {
focus: "geometric", // choices "lifelike" | "geometric"
polished: true,
verySubjectiveMeaning: true,
}
// can still be passed to this base function
statueFn(fusionStatue);
This is actually a fairly decent example of what I need deep merging for: crafting an interface from a collection of smaller ones. I'm working with a plugin system where entities can have functionality mixed and matched... class extension does not work here, so deep merging configs is the solution.
Okay, that's a lot to write. I hope this clears up some confusion. If not, maybe we'll try again? Thank you for your patience.
Types like these have a ton of what I call "policy" decisions:
It seems like the ruleset you have here says that primitives should union and object types should merge, which is definitely neither a union nor an intersection. It's a domain-specific merging rule. Writing these in userspace is totally appropriate, because you have to make a ton of decisions about how this merging works. It's not tractable for us to provide intrinsic type operators to cover the dozens of ways you could write this "merge" type.
Welp, I can already tell this proposal is dead.
Everyone knows that perfect is the enemy of good. Merging interfaces of a depth > 1 can not be done perfectly for every possible use case. It can be done well enough for people to get on with their day though.
So the answers to your question are pretty easy from a "my job it to write code and wrestling type systems wastes my time" perspective:
0 as any
in there and move on (or if fancy, use a Symbol called "Opt" or something)@ts-ignore
is not a good optionOpt as any
there. Either way, I'll be able to keep working.But, like I said above, perfect is the enemy of good. Trying to merge a couple interfaces just to get never
is a bit of a day stopper. Literally anything is better than that.
I know when a proposal is dead, and this one is very very dead. I've seen far better proposals die from perfectionist critique.
For anyone who comes across this trying to combine interfaces of a depth greater than 1, here's the best I've found yet:
export type SubpropertyMerge<T> = (
T extends object ? (
T extends string | number | ((...args: any) => any) | symbol | boolean ? T
: { [K in keyof T]: SubpropertyMerge<T[K]> }
) : T
);
As far as I can tell itās not about āgoodā vs. āperfectā, itās about having different but equally valid ways to do it, and it will likely be impossible to reach consensus on what is the best approach. That goes double if weāre talking about a built-in operator as opposed to a utility type.
As for getting never
: thatās because youāre trying to use the āintersectionā operator as a āmergeā operator, when thatās not actually what it is. A & B
means precisely āgive me a type thatās assignable to both A and Bā. Very simple and mathematical. Sometimes thatās impossible, so you get never
, meaning thereās no value that satisfies that requirement.
A & B means precisely āgive me a type thatās assignable to both A and Bā. Very simple and mathematical.
I've given you guys a lot of understanding here, and I've yet to see it returned.
It is very logical and simple. Logic and intuition don't always align. And -- as it turns out -- the intuitive answer to "what happens when you combine an ATM and a Jukebox" is not "never".
I've given you guys a lot of understanding here, and I've yet to see it returned.
I'd say you're understood by now.
the intuitive answer to "what happens when you combine an ATM and a Jukebox" is not "never".
The intuitive answer to "I want a single screen machine being both at once an ATM with an OLED screen and a Jukebox with an LCD screen" is "you can't have that, it doesn't make sense and can't exist".
You're just leaving out too many details.
I'd say you're understood by now.
Okay, then what would I give you if you asked me for a combination ATM/ Jukebox?
const strangeMachine: DeepMerge<Jukebox, Atm> =
Can you give an example of a valid value for this?
type Jukebox = { accepts: "quarters"; playsMusic: true; }
type Atm = { accepts: "cards"; hasTouchScreen: true; }
const strangeMachine: DeepMerge<Jukebox & Atm> = {
accepts: Math.random() < 0.5 ? "quarters" : "cards",
playsMusic: true,
hasTouchScreen: true,
}
An example of what I needed this for (and have since figured out) is combining configs.
I have a system in which "augments" are chained together to create a "factory." Depending on the augments, the factory will require more complex config (with more options, etc), and will produce more complex function sets. Both the configs and function sets are a product of a deep merge right now.
It's a way of getting around class inheritance, and basically the same thing as decorators but you can actually modify the type of the constructor arguments.
It's working like a charm right now, but I had to hack a bit around typescript a bit because not having a utility type made the whole thing recursively complex... it was taking 3-5 seconds for the language server to respond to any changes.
edit: changed the word "union" to "merge" to avoid confusion
Suggestion
š Search Terms
"deep union" "sub property merge" "deep merge"
ā Viability Checklist
My suggestion meets these guidelines:
ā Suggestion
Deep type merges with objects that have enums or functions props are not fully possible in Typescript, nor are their "best" implementations very performant or transparent.
It would be nice to have a generic type for deep merges that is:
debuggable -- it's easy to tell what's going on when you merge a few types
Here are few examples of how to do them now, and how they hold up
Eg 1: The one I found
string | { someProp: any }
will break the thing by unioning empty objects (I got ~100{} | {} | {}
from this).Note: if you make the output type for functions
(...args: A) => R
(instead of merging sub-properties), it seems to handle functions at least sort of decently.Eg 2: The best I could manage
undefined
was a valid valueNote: this one doesn't even attempt to merge functions. Also, the
T1 extends object ? T2 : T1 | T2
that comes right after having just checked ifT1 extends object ?
is necessary. I don't know why, and the amount of difficulty it takes to grasp this system is yet another reason I think it would be nice to just have a fully tested Utility type instead.š Motivating Example
š» Use Cases
Almost any time a user uses a normal merge
A & B
on objects, they're probably expecting a deep merge. Eg:Honestly, given how much more useful a deep merge is than a shallow one, it almost seems worthy of being
&&
rather than a utility type. Eg.A && B
āļø Complications
There's a lot of different ways of merging two functions
(X) => Y | (A) => B
(X | A) => Y | B
(DeepMerge<X, A>) => DeepMerge<Y, B>
For the sake of suggestion, I'd probably go with # 3 above because it encompasses # 1 and # 2 a lot better than either can encompass # 3. You can give # 3 the same arguments as # 1 or # 2 and the worst it will do is demand some extra properties. The other way around, and it won't have any typings and will throw errors for the excess values. It's probably better to demand something useless with proper type hints, than deny something necessary with no type hints.