-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Existential type? #14466
Comments
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 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 interface Container {
content<E>: {
a: E;
b: E[];
}
} One thing to note is that if no constraint is imposed over const c: Container = {
content: {
a: 12,
b: ["a", 6, "c"]
}
} Since Even if interface Container {
content<E extends number | string>: MyType<E>
} The above example can still be matched by Maybe what we need is an "exclusive or" type constraint, that would constrain 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 |
@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 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 ( 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 |
@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 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] |
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 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 :) . |
I'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 Trying to assign: x.prop.b[2] = "hi"; Should fail, however, reassigning x = {
prop: {
a: "hello",
b: ["world"]
}
} If 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 x.prop = {
a: "hello",
b: ["world"]
} If that would be allowed (I mean, for both the cases where |
forgive my ignorance, isn't wrapping into a closure would be a commonly accepted answer to existential types problem? |
Not in the case of declaration files, where you almost never see that. |
@isiahmeadows Surely the type of |
That is the correct understanding, but it was @rotemdan's proposed syntax, not mine - I was just translating a type between the two. The main question I had was this: what is |
@isiahmeadows possibly |
@cameron-martin Can't be |
For anyone interested in using existential types right now via the negation of universal types, the type annotation burden of doing so has been greatly reduced thanks to recent type inference improvements. type StackOps<S, A> = {
init(): S
push(s: S, x: A): void
pop(s: S): A
size(s: S): number
}
type Stack<A> = <R>(go: <S>(ops: StackOps<S, A>) => R) => R
const arrayStack = <A>(): Stack<A> =>
go => go<A[]>({
init: () => [],
push: (s, x) => s.push(x),
pop: s => {
if (s.length) return s.pop()!
else throw Error('empty stack!')
},
size: s => s.length,
})
const doStackStuff = (stack: Stack<string>) =>
stack(({ init, push, pop, size }) => {
const s = init()
push(s, 'hello')
push(s, 'world')
push(s, 'omg')
pop(s)
return size(s)
})
expect(doStackStuff(arrayStack())).toBe(2) |
@isiahmeadows I'm guessing what we're looking for here is a top type. Surely this can be build from a union of However, it would be nice to have a top type built in. |
@isiahmeadows @cameron-martin
The existential is only (alpha) equivalent Edit: An existential is assignable -> An existential variable is assignable |
@jack-williams Yeah...I meant it as "what is the smallest non-existential type |
Understood! By equivalence I wasn't sure if you wanted assignability in both directions. |
@jack-williams Assignability the other way ( |
@isiahmeadows I think I was (am) confused on the syntax, and my early comment also missed a word. It should have read: An existential variable is assignable, but not equivalent, to Top. I read the earlier comment:
and starting parsing In that sense what I intend is: When you write: TLDR; I agree that Apologies for confusion. |
@jack-williams I don't see how it could be |
@jack-williams Edit: Clean this up so it's a little easier to digest.
(I kind of followed the common miswording of "existential" instead of "universal", when it's really the latter. Not really something specific to TS - I've seen it in Haskell circles, too.) |
@cameron-martin (re: this comment) interface C {
// @rotemdan's proposal
prop<T>: T[];
// My proposal
prop: type<T> T[];
}
let c: C = {prop: [2]}
let value = c.prop[0] // What type is this? Just thought I'd catch you out on this one and point out that |
@cameron-martin : As @isiahmeadows says, the type of @isiahmeadows If |
Wait...you're correct. I got my terminology crossed up.
Either way, you can still define that Note that for many cases, this is already possible in conditional types, where an type ReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : any |
We don't really need to be quite so modest with this claim. For any covariant functor |
Your original example is a bit large so I haven't read all of it, but at a skim Instead of a hypothetical: export interface SchedulerT<Frame, Idle, T> {
nextFrame(func: () => T): Frame;
cancelFrame(frame: Frame): void;
nextIdle(func: () => T): Idle;
cancelIdle(frame: Idle): void;
nextTick(func: () => T): void;
}
type Scheduler<Frame, Idle> = exists T. SchedulerT<Frame, Idle, T> You can use: export interface Scheduler<Frame, Idle> {
nextFrame(func: () => unknown): Frame;
cancelFrame(frame: Frame): void;
nextIdle(func: () => unknown): Idle;
cancelIdle(frame: Idle): void;
nextTick(func: () => unknown): void;
} EDIT: Ah, I just realized it's probably |
You can however use a binary version of the existentials-by-way-of-universals approach, which looks like this: interface hkt2eval<a, b> { }
type hkt2 = keyof hkt2eval<unknown, unknown>
type exists2<f extends hkt2> = <r>(f: <a, b>(fe: hkt2eval<a, b>[f]) => r) => r
type some2 = <f extends hkt2>(k: f) => <a, b>(fab: hkt2eval<a, b>[f]) => exists2<f>
const some2: some2 = _ => fa => k => k(fa) For your use case: interface hkt2eval<a, b> {
Scheduler: {
nextFrame(func: () => any): a;
cancelFrame(frame: a): void;
nextIdle(func: () => any): b;
cancelIdle(frame: b): void;
nextTick(func: () => any): void;
}
}
const myScheduler = some2('Scheduler')({
nextFrame: () => 'foo',
cancelFrame: s => {
// s is known to be a string in here
console.log(s.length)
},
nextIdle: () => 21,
cancelIdle: n => {
// n is known to be a number in here
console.log(n + 21)
},
nextTick: () => { }
})
const result = myScheduler(({ nextFrame, cancelFrame, nextIdle, cancelIdle, nextTick }) => {
const a = nextFrame(() => { })
// `a` is not known to be a string in here
console.log(s.length) // error
// we can pass `a` into `cancelFrame` though (that's about all we can do with it)
cancelFrame(a)
// the argument of `cancelFrame` is not known to be a string
cancelFrame('foo') // error
}) |
@masaeedu There's also // Mithril-related definition
import m from "mithril"
type ObjectComponent<Attrs> = m.Component<Attrs, unknown>
interface Attrs { C: ObjectComponent<this> }
interface State { n: number }
export const ProxyComp: m.Component<Attrs, State> = {
// The only thing correct about this is `this.n = 1`. The rest will erroneously check.
oninit(vnode) { this.n = 1; vnode.attrs.C.oninit?.call(this, vnode) },
oncreate(vnode) { vnode.attrs.C.oncreate?.call(this, vnode) },
onbeforeupdate(vnode, old) { return vnode.attrs.C.onbeforeupdate?.call(this, vnode, old) },
onupdate(vnode) { vnode.attrs.C.onupdate?.call(this, vnode) },
onbeforeremove(vnode) { return vnode.attrs.C.onbeforeremove?.call(this, vnode) },
onremove(vnode) { vnode.attrs.C.onremove?.call(this, vnode) },
view(vnode) { return vnode.attrs.C.view.call(this, vnode) },
} While yes, valid types could be used to properly type Edit: And just to be clear, this is a case where |
Note: (This is also why System F-sub is so difficult to check - it's these kind of polymorphic dependencies that make it extremely difficult.) |
This is because Regarding the snippet itself, this is another rather tremendous example when you include the library definitions, but as far as I understand it from skimming: interface Attrs { C: m.Component<this, unknown> } If we want to replace the interface Attrs<X> { C: m.Component<this, X> }
type SomeAttrs = <R>(f: <X>(c: Attrs<X>) => R) => R And now we have: export const ProxyComp: m.Component<SomeAttrs, State> = {
oninit(vnode) {
this.n = 1;
vnode.attrs(({ C }) => C.oninit?.call(this, vnode)) // doesn't typecheck
},
oncreate(vnode) {
vnode.attrs(({ C }) => C.oncreate?.call(this, vnode)) // doesn't typecheck
},
...
} Whether the failure to typecheck is good or bad I can't say, because I don't understand what the code is supposed to be doing. A simpler/more library agnostic example might help. |
@masaeedu It's hard to give a library-agnostic one because I've rarely encountered them outside libraries, frameworks, and similar internal abstractions. But I hope this explains better what's going on. |
Interesting. Are records containing functions accepted as parameters? |
@masaeedu Yes, but they're parameterized at the record level. Specifically, return [
m("div.no-ui-slider", {
oncreate(vnode) {
vnode.state.slider = noUiSlider.create(vnode.dom, {
start: 0,
range: {min: 0, max: 100}
})
vnode.state.slider.on('update', function(values) {
model.sliderValue = values[0]
m.redraw()
})
},
onremove(vnode) {
vnode.state.slider.destroy()
},
}),
m("div.display", model.sliderValue)
] This is rather difficult to type soundly without existential types within Mithril's DT types itself, and you'd have to give several explicit type annotations. Though the more I dig into this, it's possible with a type parameter on So the invariance issue is only with components' state, and specifically when used generically (like via |
Okay, from #1213, I've stumbled across another case where this is needed. type Prefixes<A extends any[], R> = (
A extends [...infer H, any] ? Prefixes<H, R | H> : R
);
type Curried<Params extends any[], Result> = {
(...args: Params): Result;
<A extends Prefixes<Params, never>>(...args: A):
Params extends [...A, ...infer B] ? Curried<B, Result> : never;
};
function curried<F extends (...args: any[]) => any>(
fn: F
): F extends ((...args: infer A) => infer R) ? Curried<A, R> : never {
return (
(...args: any[]) => args.length == fn.length - 1
? curried(rest => fn(...args, ...rest))
: fn(...args)
) as any;
}
function a<T>(b: string, c: number, d: T[]) {
return [b, c, ...d];
}
const f = curried(a);
const curriedF: (<T>(rest: T[]) => Array<number | string | T>) = f('hi', 42);
const appliedF: Array<number | string> = f('hi', 42, [1, 2, 3]); In the above snippet, The concrete issue can be stripped down a lot further. declare function clone<F extends (a: any) => any>(
fn: F
): F extends ((a: infer A) => infer R) ? ((a: A) => R) : never;
function test<T>(a: T): T {
return a;
}
const appliedF: number = clone(test)(1); In this case,
What this translates to is a couple things:
Fixing this essentially means you have to have existential types, whether you provide syntax for it or not, because there really isn't any other way that isn't essentially just that but more complicated and/or haphazard. (cc @masaeedu - I know you were looking for things outside the purview of DOM frameworks, and this counts as one of them.) |
I went straight to the second snippet because I'm having a bit of a hard time wrapping my head around the first one. As far as the second one goes, I'm not sure what role we'd want existential types to play. AFAICT the problem is that: type ConditionalTypesDontWorkWithGenerics<F> = F extends ((a: infer A) => infer R) ? (a: A) => R : never
function test<T>(a: T): T {
return a;
}
type Result = ConditionalTypesDontWorkWithGenerics<typeof test>
// Result = (a: unknown) => unknown Whereas you'd want |
In general I'm not sure it makes much sense to expect this to work without having some explicit quantifier in the conditional type. There is no substitution of If you do put in an explicit quantifier things work out nicely: type GF = <T>(a: T) => T
declare function clone<F extends (a: any) => any>(
fn: F
): F extends GF ? GF : never;
function test<T>(a: T): T {
return a;
}
const appliedF: number = clone(test)(1); Regardless, I still don't see the connection with existential types here. |
@masaeedu While that's true, universal and existential quantifiers can be defined in terms of each other, not unlike de Morgan's laws, and so this would actually need to exist together with negated types. Specifically:
Or to put it in terms of my proposal and negated types, these two types must be equivalent:
And in addition, parameterization must be kept together when extracting conditional types. These three must all be equivalent:
Of course, we could theoretically just do a Edit: fix type inconsistency |
There are more constructive ways of relating these things. That said, why would we prefer to work with
It seems like you want the substitution |
@masaeedu A better way to put it is that
The current math looks like this:
Note the difference between step 2 of each and the addition of the lifting the
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. As far as I'm aware, your assessment of the "current math" is also wrong, in that the But I'm less confident on this point, probably someone more familiar with the implementation can comment. |
@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:
I think you got confused, where I've up until this point only really referred to You are right in that I screwed up in the current math, though, and I've corrected that in my comment. |
Unfortunately I don't think this is correct either, it's precisely the other way around. And no, |
I 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 |
Fascinating. Could you point out what I am "not fully correct" about? |
@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. |
Workaround:
That 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 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.
100% type-safe to work with. But there is boilerplate. Using the workaround above, you can operate on the property list as follows:
Here is a syntax I would suggest if we were to have the exists keyword: And the only function types that can type-safely operate on those existential types are the ones using forall. E.g.
|
I 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 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 |
@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 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 |
One 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 🧨 💥 |
Here's a case where I need a few existentials. It's for a definition file, where I need to have parameters for
Binding
andScheduler
, but it doesn't matter to me what they are. All I care about is that they're internally correct (andany
doesn'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.
The text was updated successfully, but these errors were encountered: