Repository navigation
Existential type? #14466
Description
Activity
Hi again :),
I'll try my best to clarify what this means (hopefully my understanding isn't flawed - at least I hope). I took a simple example as a starting point: of a generic type, and a non-generic container type. The container contains a property
contentwhich accepts a value matching any instantiation of the generic type, as long as it follows its internal "shape":interface MyType<T> { a: T; b: T[]; } interface Container { content<E>: MyType<E> }
Of course the above doesn't currently compile, but it demonstrates another approach to a possible syntax.
The idea is that the affected property or variable is "modified" by a type parameter, which is always inferred (for the 'write' direction it is conceptually similar to a generic setter method
setContent<E>(newContent: MyType<E>)whereEis always inferred when called). That type parameter can be passed to any secondary generic type, or even be used to define an anonymous generic type:interface Container { content<E>: { a: E; b: E[]; } }
One thing to note is that if no constraint is imposed over
E. It can always be matched withany, meaning that perhaps surprisingly, the following might work:const c: Container = { content: { a: 12, b: ["a", 6, "c"] } }
Since
Ecan be substituted withany, any type with propertiesaandband wherebis an array can satisfyMyType<E>.Even if
Ewas constrained:interface Container { content<E extends number | string>: MyType<E> }
The above example can still be matched by
E = number | string(Edit: or in practice technically alsoE = anyas well, but I was just giving that as an illustration).Maybe what we need is an "exclusive or" type constraint, that would constrain
Eto be eithernumberorstring, but not the possibility of both:interface Container { content<E extends number ^ string>: MyType<E> }
const c: Container = { content: { a: 12, b: ["a", 6, "c"] } } // <- Error this time: can't match either `number` or `string` to unify with `MyType`
Another thing to note is that the resulting container type would probably be used mostly as an abstract one, to be inherited by classes or other interfaces, or maybe serve as a constraint for type arguments. When used on its own, trying to read
contentwould return a value whose type always falls back to its existential types' constraints.dead-claudia commented
on Mar 6, 2017 AuthorMore actionsRotem Dan (@rotemdan) I see what you mean. That could work (provided it works for arguments, too). But how would something like this work? (My idea of
type<T> Foo<T>similar to Haskell'sforall a. Foo ahas a similar potential issue, too.)interface C { prop<T>: Array<T>; } let c: C = {prop: [2]} let value = c.prop[0] // What type is this?
With the information provided, this is an opaque type AFAIK.
@isiahmeadows
My approach was just an intuitive instinct - a starting point. I can't say I'm an expert in this area but I thought it might be a meaningful contribution.
I felt it might look simple and aesthetically elegant to modify the identifier itself, though that approach still doesn't cover all possible use cases. It only covers:
Properties:
interface C { prop<E>: { a: E; b: E[] }; }
Variable declaration (
const,letandvar):let c<E>: { a: E; b: E[] }
Function, method and constructor parameters:
function doSomething(c<E>: { a: E; b: E[] }, s: string); interface I { doSomething(c<E>: { a: E; b: E[] }, s: string); } class C { constructor(c<E>: { a: E; b: E[] }, s: string); }
This syntax doesn't provide a solution to introduce explicit existential-only type variables into other scopes like entire interfaces or classes, or functions. However since these scopes do allow for "universal" type parameters, these type parameters can be "wrapped" by existentials at the use site:
interface Example<T> { a: T; } let x<E>: Example<E>;
I'm reading about
forallin Haskell (I don't think I understand it 100% at this point) and investigating the flexibility of an equivalenttype <T>style syntax. I would be interested in seeing more examples for the scopes wheretype <T>can be used. Can it wrap arbitrary code blocks? can it wrap type aliases? entire constructors, getters and setters, etc?dead-claudia commented
on Mar 6, 2017 AuthorMore actionsRotem Dan (@rotemdan) I'd say the easiest way to understand existentials is through Haskell's version (Haskell/etc. normally leaves it implicit). Basically, it's a lot like
Scheduler<any, any>, except it still validates you're implementingSchedulerwith the right internal types.As a special case, consider polymorphic functions as an existing common special case:
// TypeScript type Flattener = <T>(arg: T[][]) => T[]; type Flattener = type<T> (arg: T[][]) => T[];
-- Haskell's equivalent type Flattener = [[a]] -> [a] type Flattener = forall a. [[a]] -> [a]
-
In what code positions can
type<T>be used? From your examples so far it seems like it could fit in variable declarations, properties, function and constructor parameters and type aliases. What about entire interfaces, methods, constructors? -
Since TypeScript has the
anytype and unions likenumber | string. How can the compiler decide whether a literal, say{ a: 1, b: [1, "x", "y", 4] }is compatible with a polymorphic type, saytype <E> { a: E, b: E[] }sinceEcan always be unified withany? (ornumber | stringetc).
Edit: instantiation -> literal
-
@isiahmeadows
I apologize I forgot to answer your question about:
interface C { prop<T>: Array<T>; } let c: C = {prop: [2]} let value = c.prop[0] // What type is this?
When I mentioned that it is "mostly useful in the write direction" I meant that in general it improves type safety only when written to. When read from, the type falls back to a supertype based on the constraint for the existential type parameter (which here is not provided, so I guess can be assumed to be
T extends any). So I believe the resulting type ofc.prop[0]should beany, unfortunately, unless more sophisticated flow analysis is applied (which might be able to specialize the type only for the particular variablec).Based on what I read about 'opaque' types so far, I believe this may qualify as one.
Edit: my personal feeling about this is that it is just one more example that demonstrates the 'flakiness' of mutable variables. If all TS variables and properties were immutable, the compiler could easily keep track on the 'internal' types held within an entity bound to an existential type, since once it is first inferred, it cannot be changed anymore. Due to this and many other reasons I'm personally making an effort to come up with a plan to try to move away from mutable variables in my own programming.. ASAP :) .
Reacted by Tarjei SkjærsetI'll try to demonstrate how this can work with type inference and flow analysis:
For the type:
interface C { prop<E>: { a: E; b: E[]; } }
An instance would provide a temporary instantiation, that would be determined by type inference and flow analysis, for example, when a literal is assigned:
let x: C = { prop: { a: 12, b: [1, 2, 3] } }
The declared type of
xwould remainCbut the actual one would be specialized to{ prop: { a: number; b: number[] } }(I'm ignoring, for now, the issues withanyinference I mentioned earlier).Trying to assign:
x.prop.b[2] = "hi";
Should fail, however, reassigning
xitself with a value implying a different instantiation might work, e.g.:x = { prop: { a: "hello", b: ["world"] } }
If
xwas declared withconstthen the "apparent" type ofxwould be permanently locked to{ prop: { a: number; b: number[] } }.This is at least how I imagine it could work.
Edit: fixed code examples
Edit 2:
After thinking about this for a while, I'm wondering whether
propshould be allowed to be reassigned with a value representing a different instantiation as well, e.g.:x.prop = { a: "hello", b: ["world"] }
If that would be allowed (I mean, for both the cases where
xwas declared asletandconst), then flow analysis might need to become more sophisticated as it would need to track the change of type for 'deep' properties in the object tree ofx.forgive my ignorance, isn't wrapping into a closure would be a commonly accepted answer to existential types problem?
dead-claudia commented
on Mar 6, 2017 AuthorMore actionsNot in the case of declaration files, where you almost never see that.
Reacted by Zoey and Raphael Schweikert- addedIn DiscussionNot yet reached consensusNot yet reached consensusSuggestionAn idea for TypeScriptAn idea for TypeScript
on Mar 6, 2017 @isiahmeadows Surely the type of
c.prop[0]in your example would betype<T> Twith your proposed syntax?dead-claudia commented
on Oct 17, 2017 AuthorMore actionsCameron Martin (@cameron-martin)
Surely the type of
c.prop[0]in your example would betype<T> Twith your proposed syntax?That is the correct understanding, but it was Rotem Dan (@rotemdan)'s proposed syntax, not mine - I was just translating a type between the two. The main question I had was this: what is
type<T> Tequivalent to?@isiahmeadows possibly
Object?dead-claudia commented
on Oct 18, 2017 AuthorMore actionsCameron Martin (@cameron-martin) Can't be
Object, sinceObject.create(null), an object by ES spec, should also be assignable to it, andObject.create(null) instanceof Objectisfalse.47 remaining items
dead-claudia commented
on Aug 15, 2021 AuthorMore actionsAsad Saeeduddin (@masaeedu) A better way to put it is that
<T>(a: T) => Tshould be equivalent toforall<T> ((a: T) => T)- the generics should simply be sugar for an enclosingforallconstraint. And here's how the math would work out for the previous example per my proposal here:Step Action Result 1 Initial Parameters<<T>(a: T, b: T) => unknown>2 Desugar generic to forallParameters<forall<T> ((a: T, b: T) => unknown)>3 Beta reduce forall<T> ((a: T, b: T) => unknown) extends ((...args: infer P) => any) ? P : never4 Desugar arguments to tuple forall<T> ((...args: [T, T]) => unknown) extends ((...args: infer P) => any) ? P : never5 Lift forallconstraint((...args: forall<T> [T, T]) => unknown) extends ((...args: infer P) => any) ? P : never6 Pattern-match type ((...args: forall<T> [T, T]) => unknown) extends ((...args: infer P) => any) ? P : neverwhereinfer P = forall<T> [T, T]7 Substitute value ((...args: forall<T> [T, T]) => unknown) extends ((...args: forall<T> [T, T]) => unknown) ? forall<T> [T, T] : never8 Beta reduce conditional type forall<T> [T, T]The current math looks like this:
Step Action Result 1 Initial Parameters<<T>(a: T, b: T) => unknown>2 Beta reduce (<T>(a: T, b: T) => unknown) extends ((...args: infer P) => any) ? P : never3 Resolve generic to supertype ((a: unknown, b: unknown) => unknown) extends ((...args: infer P) => any) ? P : never4 Desugar arguments to tuple ((...args: [unknown, unknown]) => unknown) extends ((...args: infer P) => any) ? P : never5 Pattern-match type ((...args: [unknown, unknown]) => unknown) extends ((...args: infer P) => any) ? P : neverwhereinfer P = [unknown, unknown]6 Substitute value ((...args: [unknown, unknown]) => unknown) extends ((...args: [unknown, unknown]) => unknown) ? [unknown, unknown] : never7 Beta reduce conditional type [unknown, unknown]Note the difference between step 2 of each and the addition of the lifting the
forallconstraint step - it's subtle, but significant.Sorry, it's hard to go into much detail without diving into type theory and mathematical logic. This gets complicated and hairy really, really fast.
Edit: Correct a couple swapped steps in the current math.
Thanks, that's much more explicit. The "Lift forall constraint" step where you turn this:
forall<T> ((...args: [T, T]) => unknown) extends ((...args: infer P) => any) ? P : never
into this:
((...args: forall<T> [T, T]) => unknown) extends ((...args: infer P) => any) ? P : never
is actually wrong.
<X> ((x: X) => Whatever)is not the same type as(x: <X> X) => Whatever. The first one is equivalent to(x: unknown) => Whatever, and the second to(x: never) => Whatever.
As far as I'm aware, your assessment of the "current math" is also wrong, in that the
unknowns for the parameters aren't introduced until we try to unify<T>(a: T, b: T) => unknownwith(...args: infer P) => anyin expanding the conditional type. Or at least, if you write a different conditional type, the quantification survives just fine, and it is possible to show that conditional types respect the upper bounds of quantifiers for generic function types.But I'm less confident on this point, probably someone more familiar with the implementation can comment.
dead-claudia commented
on Aug 15, 2021 AuthorMore actionsAsad Saeeduddin (@masaeedu) I'm not a computer scientist, so I probably didn't nail everything first try. 🙃
But I will point out one thing that might have gotten missed in the shuffle:
forall<X> Xis equivalent to the top typeunknown.exists<X> Xis equivalent to the bottom typenever.
I think you got confused, where I've up until this point only really referred to
existsand notforall. (It's also why I started explicitly denoting the two - I'm trying to not be ambiguous in my intent.) So yes,forall<X> ((x: X) => Whatever)is equivalent to(x: forall<X> X) => Whateverand thus also to(x: unknown) => Whatever.You are right in that I screwed up in the current math, though, and I've corrected that in my comment.
But I will point out one thing that might have gotten missed in the shuffle:
forall<X> Xis equivalent to the top typeunknown.exists<X> Xis equivalent to the bottom typenever.
Unfortunately I don't think this is correct either, it's precisely the other way around. And no,
forall<X> ((x: X) => Whatever)is not equivalent to(x: forall<X> X) => Whatever. You're welcome to experiment with this in other type systems with polymorphism (e.g. Haskell), or you could conduct a roughly analogous experiment in TypeScript with<T>(x: () => T) => unknownvs(x: <T>() => T) => unknown.dead-claudia commented
on Aug 15, 2021 AuthorMore actionsI think we're both partially, but not fully correct, with you more correct than me.
declare const test1: Test1; type Test1 = <T>(x: () => T) => unknown declare const test2: Test2; type Test2 = (x: <U>() => U) => unknown declare const test3: Test3; type Test3 = (x: () => unknown) => unknown declare const test4: Test4; type Test4 = (x: () => never) => unknown // Apparent hierarchy: // - Test1/Test3 subtypes Test2/Test4 // - Test1 is equivalent to Test3 // - Test2 is equivalent to Test4 // type Test1 = <T>(x: () => T) => unknown // type Test2 = (x: <U>() => U) => unknown // Result: `Test1` subtypes `Test2` const assign_test12: Test2 = test1 const assign_test21: Test1 = test2 // error // type Test1 = <T>(x: () => T) => unknown // type Test3 = (x: () => unknown) => unknown // Result: `Test1` is equivalent to `Test3` const assign_test13: Test3 = test1 const assign_test31: Test1 = test3 // type Test1 = <T>(x: () => T) => unknown // type Test4 = (x: () => never) => unknown // Result: `Test1` subtypes `Test4` const assign_test14: Test4 = test1 const assign_test41: Test1 = test4 // error // type Test2 = (x: <U>() => U) => unknown // type Test3 = (x: () => unknown) => unknown // Result: `Test3` subtypes `Test2` const assign_test23: Test3 = test2 // error const assign_test32: Test2 = test3 // type Test2 = (x: <U>() => U) => unknown // type Test4 = (x: () => never) => unknown // Result: `Test2` is equivalent to `Test4` const assign_test24: Test4 = test2 const assign_test42: Test2 = test4 // type Test3 = (x: () => unknown) => unknown // type Test4 = (x: () => never) => unknown // Result: `Test3` subtypes `Test4` const assign_test34: Test4 = test3 const assign_test43: Test3 = test4 // error
It appears a reduction for the sake of conditional type matching is possible, but only from
Test1/Test3toTest2/Test4. So my reduction is sound, but I can't go in the other direction - the reduction is one-way, and it can only be done for cases where direct equivalence is irrelevant (like conditional type extraction and assignability).I think we're both partially, but not fully correct, with you more correct than me.
Fascinating. Could you point out what I am "not fully correct" about?
dead-claudia commented
on Aug 15, 2021 AuthorMore actionsAsad Saeeduddin (@masaeedu) I'm specifically referring to the subtyping bit - you're right that they're not equivalent (thus countering almost my entire rationale), but I'm right in that it can still be reduced like that in this particular situation anyways. So essentially, I'm right about the claim it can be done, just you're right about the supporting evidence provided being largely invalid and about why it's largely invalid.
Reacted by Asad SaeeduddinWorkaround:
type Exists<T> = <R>(doIt: <A>(a: T<A>)=>R)=>RThat is to say, you represent the type as a callback for the visitor pattern pattern to operate on the unknown type. Unfortunately the pattern requires higher kinded types to avoid boilerplate. So you need to manually make several Exist types for each type of
T.One solid use case for the exists operator is a Properties List table holding properties of many different types each tupled with a renderer and/or editor that operates on those types.
E.g.
type Property<A> = { displayName: string, getValue: () => A, setValue: (a: A) => void, renderer: Renderer<A>, editor: Editor<A>, }; type PropertyExists = <R>(doIt: <A>(a: Property<A>) => R) => R; type PropertyList = PropertyExists[];100% type-safe to work with. But there is boilerplate.
Using the workaround above, you can operate on the property list as follows:
function operateOnPropertyList(pl: PropertyList) { for (let p of pl) { p(operateOnProperty); } } function operateOnProperty<A>(p: Property<A>) { // Do stuff with it }Here is a syntax I would suggest if we were to have the exists keyword:
type PropertyList = (<exists A>: Property<A>)[];And the only function types that can type-safely operate on those existential types are the ones using forall.
E.g.
function operateOnPropertyList(pl: PropertyList) { for (let p of pl) { operateOnProperty(p); } } function operateOnProperty<A>(p: Property<A>) { // Do stuff with it }Reacted by Christopher Hiller, Jared Poole, Andre Wachsmuth, nevadaperry, Ege Güngördü, azerum, Kartal Kaan Bozdoğan, Hans Brende, Curtis Fenner, Trevor Smith and 1 moreI just ran into this problem. (Apologies if this use-case has already been presented.) Given the following type:
type KeyObjectPair<OBJ> = readonly [keyof OBJ, OBJ]
The problem is I need to discard the information about
OBJafter such a pair is created, while ensuring that it was type-checked by someOBJtype at creation-time. [Notice thatOBJis invariant in this type signature, otherwise I would be able to simply replace it withunknownorneverand call it a day!]So that I can do stuff like this later:
type KeyObjectPairArray = KeyObjectPair<?>[] // or: type KeyObjectPairArray = (<exists OBJ> KeyObjectPair<OBJ>)[] // or whatever the syntax may be // So that: const x: KeyObjectPairArray = [['key', {key: 'value'}], ['k', {k: 'value'}]] // Works! const x: KeyObjectPairArray = [['key', {}]] // Compile error!
and functions like this will continue to work later on this "erased" KeyObjectPair:
function getValue<OBJ>([key, obj]: KeyObjectPair<OBJ>): OBJ[keyof OBJ] { return obj[key]; } function getValueErased(pair: KeyObjectPair<?>): unknown { return getValue(pair) // Works! }
I've looked through the various workarounds posted such as the
<R>(doit: <A>(a: MyType<A>) => R) => Rhack, however this seems to be very unpleasant to include in a user-facing library.Reacted by Tamas BaraszHans Brende (@HansBrende), that is a good use case for existential type and it is currently impossible to represent in TypeScript. There is one workaround, which requires a run time change. You can wrap all of it in a function that returns a callback function which takes callback function that will return the result.
type KeyObjectPair<OBJ extends Record<string, unknown>> = readonly [ keyof OBJ, OBJ, ] const createKeyObjPairWrapper = <OBJ extends Record<string, unknown>>(keyObjPair: KeyObjectPair<OBJ>) => <RESULT>(callback: (obj: KeyObjectPair<OBJ>) => RESULT): RESULT => callback(keyObjPair) const wrappedKeyObjPair1 = createKeyObjPairWrapper(['a', { a: 1, b: 2 }]) const wrappedKeyObjPair2 = createKeyObjPairWrapper(['x', { x: 'X', y: 'Y' }]) const wrappedKeyObjPairList = [wrappedKeyObjPair1, wrappedKeyObjPair2] const getValue = <OBJ extends Record<string, unknown>>([ key, obj, ]: KeyObjectPair<OBJ>) => obj[key] const value1 = wrappedKeyObjPair1(getValue) const value2 = wrappedKeyObjPair1(getValue) const listResult = wrappedKeyObjPairList.map((callback) => callback(getValue))
Now the wrappers will take any function that takes a
KeyObjectPairand does anything to it, that is valid for the generic type it has.This might seem unnecessary, but is actually the only way I know how to make this work.
There is one other solution though, and that is to really ask yourself if you need
OBJto be invariant. In a lot of cases you can actually find a solution, where you can just type your function explicitly. I am not saying this is the case here, but it is more often then not. Props to Moritz Andrich (@MoritzR) for teaching me about this sorcery.Reacted by Hans BrendeOne additional incredible use-case I just thought of! If I'm not mistaken, existential types would allow us to represent INTEGERS in a typesafe way (probably just one of many amazing constructions we could do along these lines).
Here's how that could happen:
// Here's what typescript allows so far: type AssertInt<N extends number> = `${N}` extends `${bigint}` ? N : never; const assertInt = <N extends number>(n: AssertInt<N>) => n; assertInt(23); // Works assertInt(23.5); // Very very cool: compiler error!
All that is pretty dang cool, but here is the final missing piece via existential types:
// The final missing piece: type Int = AssertInt<?> // i.e., type Int = <exists N extends number> AssertInt<N> const ints: Int[] = [1, 2, 3, 4] // valid const ints: Int[] = [1, 2, 3, 4.5] // compiler error
🧨 💥
Reacted by Sebastian Hädrich, Joscha Götzer, Jeremiah Tabb, DrGrognon, Trevor Smith and Nick RichmondI think this is a common use case:
type MutationButtonProperties<Data> = { functionCall: () => Promise<Data | null> onSuccess?: (data: Data | null) => void } function createButtons(props: MutationButtonProperties<???>[])
I understand there are magic solutions for this (e.g. proposed by #14466 (comment)), but I wouldn't want to use advanced level TS sourcery in a simple use case like this
I think this is a common use case:
type MutationButtonProperties<Data> = { functionCall: () => Promise<Data | null> onSuccess?: (data: Data | null) => void } function createButtons(props: MutationButtonProperties<???>[])
I understand there are magic solutions for this (e.g. proposed by #14466 (comment)), but I wouldn't want to use advanced level TS sourcery in a simple use case like this
For simple use cases like this there is an easier solution, achieved by combining both functions:
type MutationButtonProperties = () => Promise<void> function createButtons(props: MutationButtonProperties[])
If you really need to be able to observe the intermediate null value you can forward it by returning something like
Promise<"had data" | "got null">instead ofPromise<void>.If anyone is interested in playing with existential types I have a PR open here that you can run locally.
Here's how #14466 (comment) looks...
And here's how #14466 (comment) looks... (although not the intended use case for the PR)
Note that the PR is basic and doesn't handle all the cases but feedback is welcome.
Reacted by Moritz Andrich, Clinton Selke, Claudia Meadows, Hans Brende, awa-xima, Jordan Smith, Reinis Vesers, Raphael Schweikert and irfanstractReacted by Hans BrendeReacted by Hans Brende and Clement HannicqReacted by Hans Brendeany chance the linked PR would get revived?
Here's a case where I need a few existentials. It's for a definition file, where I need to have parameters for
BindingandScheduler, but it doesn't matter to me what they are. All I care about is that they're internally correct (andanydoesn't cover this), and I'd rather not simulate it by making the constructor unnecessarily generic.The alternative for me is to be able to declare additional constructor-specific type parameters that don't carry to other methods, but existentials would make it easier.