microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.27k stars 12.39k forks source link

Existential type? #14466

Open dead-claudia opened 7 years ago

dead-claudia commented 7 years ago

Here's a case where I need a few existentials. It's for a definition file, where I need to have parameters for Binding and Scheduler, but it doesn't matter to me what they are. All I care about is that they're internally correct (and any 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.

export interface Scheduler<Frame, Idle> {
    nextFrame(func: () => any): Frame;
    cancelFrame(frame: Frame): void;
    nextIdle(func: () => any): Idle;
    cancelIdle(frame: Idle): void;
    nextTick(func: () => any): void;
}

export interface Binding<E> extends Component {
    binding: E;

    patchEnd?(): void;

    patchAdd?(
        prev: string | E | void,
        next: string | E | void,
        pos: number,
    ): void;

    patchRemove?(
        prev: string | E | void,
        next: string | E | void,
        pos: number
    ): void;

    patchChange?(
        oldPrev: string | E | void,
        newPrev: string | E | void,
        oldNext: string | E | void,
        newNext: string | E | void,
        oldPos: number,
        newPos: number
    ): void;
}

export class Subtree {
    constructor(
        onError?: (err: Error) => any,
        scheduler?: type<F, I> Scheduler<F, I>
    );

    // ...
}

export class Root extends Subtree {
    constructor(
        component: type<E> Binding<E>,
        onError?: (err: Error) => any,
        scheduler?: type<F, I> Scheduler<F, I>
    );

    // ...
}
rotemdan commented 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.

dead-claudia commented 7 years ago

@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.

rotemdan commented 7 years ago

@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?

dead-claudia commented 7 years ago

@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]
rotemdan commented 7 years ago

Edit: instantiation -> literal

rotemdan commented 7 years ago

@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 :) .

rotemdan commented 7 years ago

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.

zpdDG4gta8XKpMCd commented 7 years ago

forgive my ignorance, isn't wrapping into a closure would be a commonly accepted answer to existential types problem?

dead-claudia commented 7 years ago

Not in the case of declaration files, where you almost never see that.

cameron-martin commented 6 years ago

@isiahmeadows Surely the type of c.prop[0] in your example would be type<T> T with your proposed syntax?

dead-claudia commented 6 years ago

@cameron-martin

Surely the type of c.prop[0] in your example would be type<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?

cameron-martin commented 6 years ago

@isiahmeadows possibly Object?

dead-claudia commented 6 years ago

@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.

pelotom commented 6 years ago

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)
cameron-martin commented 6 years ago

@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.

dead-claudia commented 6 years ago

@cameron-martin Look here

jack-williams commented 6 years ago

@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

dead-claudia commented 6 years ago

@jack-williams Yeah...I meant it as "what is the smallest non-existential type type<T> T is assignable to", not formal nominal equivalence.

jack-williams commented 6 years ago

Understood! By equivalence I wasn't sure if you wanted assignability in both directions.

dead-claudia commented 6 years ago

@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.)

jack-williams commented 6 years ago

@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.

cameron-martin commented 6 years ago

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.

@jack-williams I don't see how it could be T - there is no T in scope at this point.

dead-claudia commented 6 years ago

@jack-williams Edit: Clean this up so it's a little easier to digest.

  1. 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)
  2. 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.

  3. To clear up any remaining confusion regarding the syntax:

    • My proposal is adding a type<T> T as a type expression.
    • @rotemdan proposed here to instead use prop<T>: T as an object/interface property.
    • See my next comment for explanation on how these compare.

(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.)

dead-claudia commented 6 years ago

@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.

jack-williams commented 6 years ago

@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?

dead-claudia commented 6 years ago

@jack-williams

My understanding was that forall T. T is the intersection of all types, and exists T. T was the union.

Wait...you're correct. I got my terminology crossed up.

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
zhirzh commented 5 years ago

@pelotom can you give a simpler snippet that highlights your implementation? perhaps one that doesn't have currying & HOFs mixed together?

pelotom commented 5 years ago

@zhirzh currying and HOFs are essential to encoding existential types in this way...

zhirzh commented 5 years ago

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

petejohanson commented 5 years ago

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.

jcalz commented 5 years ago

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!

AnyhowStep commented 5 years ago

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.


The problem

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.


Existing Solutions

Use MyGenericType<any>

Pros:

Cons:

Here is a Playground demonstrating the MyGenericType<any> workaround and its pitfalls.


Use MyGenericType<ConstraintType>

Pros:

Cons:

Here is a Playground demonstrating the MyGenericType<ConstraintType> workaround and its pitfalls.


Use MyGenericType<never>

Pros:

Cons:

Here is a Playground demonstrating the MyGenericType<never> workaround and its pitfalls.


Write a non-generic interface, IMyGenericType, to mimic MyGenericType<T>

  1. When T is used as an output, substitute T with ConstraintType
  2. When T is used as an input, substitute T with never

Pros:

Cons:

Here 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).


Proposal

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:

Cons:


Behaviour


Use Cases

Dealing with that pesky global 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;

Using an arbitrary concrete type of a generic type safely

The whole reason we have this proposal in the first place.


TODO More use cases

jack-williams commented 5 years ago

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?

AnyhowStep commented 5 years ago

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 =(

AnyhowStep commented 5 years ago

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,

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 : ? };
fatcerberus commented 5 years ago

@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.

jack-williams commented 4 years ago

@AnyhowStep, your proposal has effectively been described here: https://github.com/microsoft/TypeScript/pull/30639#issuecomment-479283374

AnyhowStep commented 4 years ago

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...

jack-williams commented 4 years ago

I think the difference between them is a one boolean negation of the initial variance.

dead-claudia commented 4 years ago

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.

cwohlman commented 4 years ago

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)
  }
}
masaeedu commented 3 years ago

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
masaeedu commented 3 years ago

@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)
    }
}
dead-claudia commented 3 years ago

@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).

masaeedu commented 3 years ago

@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>.

dead-claudia commented 3 years ago

@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.

Here's that code snippet with existentials removed. ```ts interface Job { // Return type is conditional as we want to break if the type hierarchy // isn't correct. isJob(param: T): this is Job; run(param: T): R; } class JobsHandler { jobs: Job = [] runJob(thing: T): R { for (const job of this.jobs) { // Extra type parameters to explicitly select the return value we want if (job.isJob(thing)) return job.run(thing) } throw new Error('No job') } addJob(job: Job) { this.jobs.push(job) } } ```
masaeedu commented 3 years ago

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>.

masaeedu commented 3 years ago

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.

masaeedu commented 3 years ago

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
})

Playground link

dead-claudia commented 3 years ago

@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) },
}

Playground Link

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.