Open dead-claudia opened 7 years ago
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 content
which 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>)
where E
is 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 with any
, meaning that perhaps surprisingly, the following might work:
const c: Container = {
content: {
a: 12,
b: ["a", 6, "c"]
}
}
Since E
can be substituted with any
, any type with properties a
and b
and where b
is an array can satisfy MyType<E>
.
Even if E
was 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 also E = any
as well, but I was just giving that as an illustration).
Maybe what we need is an "exclusive or" type constraint, that would constrain E
to be either number
or string
, 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 content
would return a value whose type always falls back to its existential types' constraints.
@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's forall a. Foo a
has 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
, let
and var
):
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 forall
in Haskell (I don't think I understand it 100% at this point) and investigating the flexibility of an equivalent type <T>
style syntax. I would be interested in seeing more examples for the scopes where type <T>
can be used. Can it wrap arbitrary code blocks? can it wrap type aliases? entire constructors, getters and setters, etc?
@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 implementing Scheduler
with 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 any
type and unions like number | string
. How can the compiler decide whether a literal, say { a: 1, b: [1, "x", "y", 4] }
is compatible with a polymorphic type, say type <E> { a: E, b: E[] }
since E
can always be unified with any
? (or number | string
etc).
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 of c.prop[0]
should be any
, unfortunately, unless more sophisticated flow analysis is applied (which might be able to specialize the type only for the particular variable c
).
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 x
would remain C
but the actual one would be specialized to { prop: { a: number; b: number[] } }
(I'm ignoring, for now, the issues with any
inference I mentioned earlier).
Trying to assign:
x.prop.b[2] = "hi";
Should fail, however, reassigning x
itself with a value implying a different instantiation might work, e.g.:
x = {
prop: {
a: "hello",
b: ["world"]
}
}
If x
was declared with const
then the "apparent" type of x
would 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 prop
should 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 x
was declared as let
and const
), 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 of x
.
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 c.prop[0]
in your example would be type<T> T
with your proposed syntax?
@cameron-martin
Surely the type of
c.prop[0]
in your example would betype<T> T
with your proposed syntax?
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 type<T> T
equivalent to?
@isiahmeadows possibly Object
?
@cameron-martin Can't be Object
, since Object.create(null)
, an object by ES spec, should also be assignable to it, and Object.create(null) instanceof Object
is false
.
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 {}
and all the remaining things that are not assignable to {}
, e.g. {} | null | undefined
.
However, it would be nice to have a top type built in.
@cameron-martin Look here
@isiahmeadows @cameron-martin
The main question I had was this: what is
type<T> T
equivalent to?
The existential is only (alpha) equivalent type<T> T
. You can eliminate the existential to a skolem constant T
, but that will only be equivalent to itself. An existential variable is assignable, but not equivalent, to Top.
Edit: An existential is assignable -> An existential variable is assignable
@jack-williams Yeah...I meant it as "what is the smallest non-existential type type<T> T
is assignable to", not formal nominal equivalence.
Understood! By equivalence I wasn't sure if you wanted assignability in both directions.
@jack-williams Assignability the other way (let foo: type<T> T = value as {} | null | undefined
) would just be application: just let T = {} | null | undefined
. (That part was implied.)
@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:
Surely the type of c.prop[0] in your example would be
type<T> T
with your proposed syntax?
and starting parsing type<T> T
as just a variable, but the syntax should really mean exists T. T
.
In that sense what I intend is: c.prop[0]
should be type T
and not type <T> T
, and that T
is assignable to Top
but not the other way round.
When you write: let foo: type<T> T = value as {} | null | undefined)
and mean that type <T> T
is equivalent to Top
then I agree.
TLDR; I agree that type<T> T
is equivalent to top, but c.prop[0] would have type T
, not type<T> T
. I got muddled on syntax.
Apologies for confusion.
In that sense what I intend is: c.prop[0] should be type
T
and nottype <T> T
, and that T is assignable to Top but not the other way round.
@jack-williams I don't see how it could be T
- there is no T
in scope at this point.
@jack-williams Edit: Clean this up so it's a little easier to digest.
I mean "structurally equivalent" in that both are assignable to each other, if that helps:
type unknown = {} | null | undefined
declare const source: any
declare function consume<T>(value: T): void
// Both of these check iff they are structurally equivalent
consume<type<T> T>(source as unknown)
consume<unknown>(source as type<T> T)
type<T> T
is supposed to be "forall T. T", not exists T. T
. True existentially quantified types would be essentially opaque, and there's not really a lot of use for that IMHO when unknown
and the proposed nominal types would address 99% of use cases existentially quantified types could solve.
To clear up any remaining confusion regarding the syntax:
type<T> T
as a type expression.prop<T>: T
as an object/interface property.(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 c.prop
itself is the quantified type, not members of it.
@cameron-martin : As @isiahmeadows says, the type of prop
is quantified over. To access the array to you need to eliminate the quantifier, doing this introduces a fresh, rigid type variable T
.
@isiahmeadows If type <T> T
denotes forall
then should that not be equivalent to bottom? My understanding was that forall T. T
is the intersection of all types, and exists T. T
was the union. What is the difference between 'true existential types' and the existential-like types that you get from the forall
encoding?
@jack-williams
My understanding was that
forall T. T
is the intersection of all types, andexists T. T
was the union.
Wait...you're correct. I got my terminology crossed up.
forall T. T
could be assigned to any type T
, as it would just happen to be a particular specialization of it. (This makes it equivalent to never
.)exists T. T
, as it would just specialize that type for that particular type. (This makes it equivalent to {} | null | undefined
.)Either way, you can still define that exists T. T
in terms of forall T. T
, using forall r. (forall a. a -> r) -> r
, where a
is existentially qualified. In my syntax, it'd look like this: type <R> (x: type <A> (a: A) => R) => R
.
Note that for many cases, this is already possible in conditional types, where an infer
keyword operator enables the common need to extract a return value from a function, like in here (which will become a predefined global type):
type ReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : any
@pelotom can you give a simpler snippet that highlights your implementation? perhaps one that doesn't have currying & HOFs mixed together?
@zhirzh currying and HOFs are essential to encoding existential types in this way...
even so, isn't there a simpler way to showcase the approach? i found one here but when it comes to examples, the more the merrier
I'm hitting this very issue w/ working on a binding for the upcoming V2 of jorgebucaran/hyperapp where you can optionally have a tuple like [SomeFunc<T>, T]
where the T
needs to be internally consistent, but shouldn't be exposed beyond that. Curious if there's any actual desire to implement this for TS.
From #31894
Are these just named bounded existential types?
~In part, yes! When no implementation type exists, a placeholder type acts as a bounded existential type variable.~
Sorry I'm not sure what you're talking about. Please move along and don't write blog posts about how TypeScript is adding bounded existential types.
Hey, I think they're making fun of us!
So, not sure if this is the right place for it. If it isn't, just tell me and I'll hang my head in shame and leave quietly.
We are using a generic type, but do not care what concrete type it is.
There is no real safe or easy way to do this at the moment.
MyGenericType<any>
Pros:
MyGenericType<T>
be assignable to MyGenericType<any>
.Cons:
MyGenericType<any>
be assignable to every other MyGenericType<T>
(unsafe)T
are unsafeT
are unsafeHere is a Playground demonstrating the MyGenericType<any>
workaround and its pitfalls.
MyGenericType<ConstraintType>
Pros:
T
are safeCons:
T
are unsafeMyGenericType<ConstraintType>
only works if T
is only used in output positionsT
is both input and outputT
is inputHere is a Playground demonstrating the MyGenericType<ConstraintType>
workaround and its pitfalls.
MyGenericType<never>
Pros:
T
are safeCons:
T
are unsafeMyGenericType<never>
only works if T
is only used in input positionsT
is both input and outputT
is outputHere is a Playground demonstrating the MyGenericType<never>
workaround and its pitfalls.
IMyGenericType
, to mimic MyGenericType<T>
T
is used as an output, substitute T
with ConstraintType
T
is used as an input, substitute T
with never
Pros:
T
are safe (will just return ConstraintType
)T
are safe (can only write never
)MyGenericType<T>
be assignable to IMyGenericType
IMyGenericType
be assignable to MyGenericType<T>
(unless it's safe or T
is any
)Cons:
T
can be tiresome; especially if done many times and for many librariesHere is a Playground demonstrating the IMyGenericType
workaround and... It's lack of pitfalls (but it adds one new type per generic type we want to workaround)
Usually, one just uses MyGenericType<any>
or writes an interface that is structurally similar to MyGenericType
(or I do, at least).
Introduce an existential type.
Based on @fatcerberus talking about his personal project
I'm leaning towards using the type
keyword for it.
So, the solution would be,
MyGenericType<type>
In effect, it is the IMyGenericType
workaround, but with far fewer cons.
Pros:
T
are safe (will just return ConstraintType
)T
are safe (can only write never
)MyGenericType<T>
be assignable to MyGenericType<type>
MyGenericType<type>
be assignable to MyGenericType<T>
(unless it's safe or T
is any
)T
for never
or ConstraintType
depending on input or output positionsT
is covariant, contravariant, or invariant; it'll work and be safeCons:
type
becomes never
.type
becomes the constraint type.type
becomes unknown
.name
!Right now, it is declared as,
declare const name: never;
I would have preferred,
declare const name : unknown;
This would make reads safer.
However, we can use the existential type
,
declare let name : type;
//Error, cannot assign `unknown` to `string`
const readNameError : string = name;
//Error, cannot assign `string` to `never`
name = "Hello, world";
//OK!
const readNameOk : string = name as string;
//OK!
name = "Hello, world" as never;
The whole reason we have this proposal in the first place.
TODO More use cases
If name
is defined by const
how can you assign to it? (or do you want name defined by let
?)
@AnyhowStep I think one thing that the proposal might benefit from is a comparison with inference sigils: MyGenericType<*>
(#26242). What are the pros/cons between them. Is there space for both?
Yeah, I want name
to be mutable.
I personally have never used window.name
but I can certainly see use cases for letting it be mutable.
If they stick to const name
, then making the type unknown
would be way better (imo)
Okay, I admit that name
example was... Contrived. But name
really annoys me =(
With reference to #26242 , the two seem like different proposals.
So, it seems there is space for both.
It looks like partial type arguments will resolve T
by attempting to infer it, or resolving to ConstraintType
.
This existential type is a new type on its own (or behaves that way).
Writes to it will be never
.
Reads to it will be ConstraintType
(or unknown
if no constraint type exists).
So, there is no type inference.
Also, it looks like the syntax for partial type inference means there's no new type introduced.
You cannot write the following,
declare let x : *;
declare let x : infer;
declare let x : auto;
and have TS infer the type of x
based on usage.
But this proposal is an entirely new type and you could write,
declare let x : type;
And x
will be unknown
for reads, and never
for writes.
Related to this,
@webstrand had a pretty cool idea,
Wouldn't it be neat if we could specify assignment rules by using the getter and setter syntax?
declare let name: { get this(): unknown; set this(value: never): unknown };
Which reveals one pitfall of my current suggestion;
If type
is used outside of a type parameter, then all reads to it will always be unknown
.
I'm not too sure if that's a problem, though.
I can't think of a use case where we use it in a non-generic context and want writes to be never
and reads to be a concreate type like string
or Date
.
Even if that idea might be cool.
I'm also not too attached to the idea of type
being the keyword used.
My rationale was,
declare let name : type
)And type
seemed to fit out of the list I found here
Oh, I was thinking it must be a valid identifier because of emit.
//.ts
type Foo<T> = { x : T };
export declare const foo : Foo<type>;
//.d.ts
export declare const foo : { x : type };
Since TS could arbitrarily expand a type alias, if we use an invalid identifier for the existential type, what happens during emit?
//.ts
type Foo<T> = { x : T };
//Assume we use `?` as the existential type
export declare const foo : Foo<?>;
//.d.ts
//Invalid identifier `?`
export declare const foo : { x : ? };
@jack-williams Yeah, this is different from partial inference - this is a way to say “I need some kind of Foo<T>
but it doesn’t matter what T
is”. Foo<any>
is the easiest way to accomplish that today (because we want to ignore variance for this purpose) but is very unsafe.
@AnyhowStep, your proposal has effectively been described here: https://github.com/microsoft/TypeScript/pull/30639#issuecomment-479283374
I tried reading that comment but got stuck at this part,
reading" permissive type and "writing" permissive type
I'm actually asking for a type that is reading and writing restrictive. So, you know the variable is of a certain type but you can't write to it (because it is never
on write) and reading from it gives you unknown
.
But maybe we describing the same thing with different words...
I think the difference between them is a one boolean negation of the initial variance.
Just ran into another issue where I need traditional existentials: typing pairs correctly. For instance, I have code that's roughly this:
type Receiver<T extends {}> = [keyof T, (value: T[keyof T]) => void]
I'm using this to represent pairs of [prop, callback]
where callback
is always called as callback(host[prop])
for some prop
in host
. So this type is actually completely incorrect. To properly type this, I need existential types that get propagated opaquely, because I want stuff like this to not error:
interface Event { a: string, b: number }
const receiver: Receiver<Event> = getReceiverSomehow()
const host: Event = getEventSomehow()
const [prop, callback] = receiver
callback(host[prop]) // currently errors
Edit: Wrong language tags for the code blocks.
Existential types would also be useful for arrays of generics.
Example:
type Job<T, R> = { isJob(param: unknown): param is T, run(param: T): R }
class JobsHandler {
jobs: Job<unknown, unknown>[] = [] // This breaks because of the error listed below
// jobs: Job<any, any>[] = [] // too loose
// jobs: Job<T, R>[] = [] // with existential types
runJob<T, R>(thing: T): R {
const job = this.jobs.find(job => job.isJob(thing));
if (!job) {
throw new Error('No job')
}
return job.run(thing); // ts error here, even though thing is guaranteed to be T
}
addJob<T, R>(job: Job<T, R>) {
this.jobs.push(job)
}
}
I think it should be possible to model existential types using the standard duality trick (although it gets a bit annoying because of lack of first class higher kinded types):
type hktval<a> = {
array: readonly a[]
}
type hkt = keyof hktval<unknown>
type exists<f extends hkt> = <r>(f: <e>(fe: hktval<e>[f]) => r) => r
type inject = <f extends hkt, a>(fa: hktval<a>[f]) => exists<f>
const inject: inject = fa => k => k(fa)
const someArray: exists<'array'> = inject([1, 2, 3])
const result = someArray(xs => xs.length) // typechecker ensures you remain agnostic to the type of things inside `xs` here
@cwohlman Is this perhaps what you want?
type Job<T, R> = { isJob(param: unknown): param is T, run(param: T): R }
// Just moved the quantification out here
class JobsHandler<T, R> {
jobs: Job<T, R>[] = []
runJob(thing: T): R {
const job = this.jobs.find(job => job.isJob(thing));
if (!job) {
throw new Error('No job')
}
return job.run(thing);
}
addJob(job: Job<T, R>) {
this.jobs.push(job)
}
}
@cwohlman Your guard is off. It needs to constrain the job, not the parameter directly.
(Using exists
here (as in "there exists") to also better clarify where the existentials are for everyone else, though of course, I'm not tied to any particular syntax provided it's not too verbose.)
interface Job<T, R> {
isJob<U extends T>(param: U): this is Job<U, R>;
run(param: T): R;
}
class JobsHandler {
jobs: Array<exists <T, R> Job<T, R>> = []
runJob<T, R>(thing: T): R {
// Avoid a type check issue by using a loop with TS's narrowing rather than
// `.find`, because TS isn't quite *that* intelligent.
for (const job of this.jobs) {
if (job.isJob(thing)) return job.run(thing)
}
throw new Error('No job')
}
addJob<T, R>(job: Job<T, R>) {
this.jobs.push(job)
}
}
@masaeedu The existential is needed if you want to make it fully generic - @cwohlman just elided it (either erroneously assuming broader familiarity with languages like Haskell or just by mistake is my guess).
@isiahmeadows Could you expand on what you mean by "fully generic"?
I was looking at the snippet above, and exists <T, R> Job<T, R>
suggests that it is impossible to call the run
method of any job in that array, and impossible to do anything with the result were we able to call it. I'm not sure why we'd want that, but if we do, we can also get it with Job<never, unknown>
.
@masaeedu I think you're conflating top/bottom types with existential quantifiers. (Wikipedia link if you want to read a little more on the math behind it) While yes, something like forall t: t
is equivalent to the bottom type (as it satisfies everything) and exists t: t
is equivalent to the top type (as it satisfies nothing but itself), that's only a very small subset of what existential and universal types can do.
In this case, now that I look closely and think more about the existentials in question, you could use never
and unknown
for this, but it moderately complicates it in this case (existentials almost trivialize it when put in the right places) and this isn't always the case in general - see my initial issue comment for an example. Another example is Mithril's object components that happen to be polymorphic - there's no function to bind type parameters to (which mathematically speaking are technically existentials as well), but you still need an existential somewhere in the mix.
While yes, something like forall t: t is equivalent to the bottom type (as it satisfies everything) and exists t: t is equivalent to the top type (as it satisfies nothing but itself)
We don't really need to be quite so modest with this claim. For any covariant functor F
, exists X. F<X>
is equivalent to F<unknown>
, and forall x. F<X>
is equivalent to F<never>
. For a contravariant functor F
, the situation is reversed: exists X. F<X>
is equivalent to F<never>
, and forall X. F<X>
is equivalent to F<unknown>
.
this isn't always the case in general - see my initial issue comment for an example.
Your original example is a bit large so I haven't read all of it, but at a skim Scheduler
appears to be covariant in the positions you've marked as any
, and so is susceptible to the same approach.
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 Frame
and Idle
that you want to existentially quantify over, I misunderstood the intent behind "(and any
doesn't cover this)". If that's the case, yes, this doesn't degenerate to top and bottom types, since Scheduler
is invariant in both parameters.
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 Binding<T>
, a different invariant type. And for Mithril (a much more real-world use case), object components (and really also closure component instances) should really be typed as exists<State> Component<Attrs, State>
to be typed correctly. Code like this is generally invalid as state isn't checked for compatibility, and neither never
nor unknown
properly encapsulate the needed constraints:
// 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 Comp
, you don't get the needed type errors from doing it the above way - you have to have existentials to correctly reject the above invalid code. In effect, it allows you to enforce better soundness in a lot of cases where you would otherwise use unknown
or never
, and an important part of verification is knowing it will tell you you're wrong when you're wrong.
Edit: And just to be clear, this is a case where exists<T> F<T, U>
is not equivalent to either F<unknown, T>
or F<never, T>
- in reality you could type it as never
, but as State
above isn't assignable to never
, that makes it impossible to type using that. And of course, unknown
is explained sufficiently well above.
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.