microsoft / TypeScript

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

Allow object types to have property-like associated types #17588

Open dead-claudia opened 7 years ago

dead-claudia commented 7 years ago

Edit: Add static Type variant for classes, make abstractness a little clearer, clarify things.

This has probably already been asked before, but a quick search didn't turn up anything.

This is similar, and partially inspired by, Swift's/Rust's associated types and Scala's abstract types.


Rationale

Proposed Syntax/Semantics

Here's what that would look like in syntax:

// Interfaces
interface Foo {
    type Type: *; // abstract
    type Sub: * extends Type; // abstract, constrained
    type Type: Default;
    type Type: * extends Type = Default; // late-bound default
}

// Objects
type Foo = {
    type Type: Foo,
}

// Classes
abstract class Foo {
    // Note: outer class *must* be abstract for these, and the keyword is required.
    abstract type Type: *; // abstract
    private abstract type Sub: * extends Type; // abstract, constrained
    protected abstract type Type: * extends Type = Default, // late-bound default

    // Note: outer class *may* be not abstract for these.
    type Type: Default;
    private type Type: Default;

    // Declare an associated type in the class
    // Note: type must not be abstract.
    static Type: Foo;
}

Emit

This has no effect on the JavaScript emit as it is purely type-level.

Compatibility

Other

RyanCavanaugh commented 7 years ago

Can you show a few small examples of how the motivating cases get solved using this proposal?

dead-claudia commented 7 years ago

@RyanCavanaugh

Edit: Be a little more rigorous with typing, update per proposal revision

I just did a little more searching and found that this is effectively a more detailed dupe of #9889, but using less ivory-tower terminology. But anyways...

Here's an example for a theoretical vnode structure based on Mithril's, demonstrating both :

// Note: I've specifically removed the fragment-related types and some of the
// more specific constraints for simplicity

export type VNode = DOMVNode<Attributes> | ComponentVNode<Component>;

interface _Lifecycle {
    type VNode: * extends VNode;
    type State: this;

    oninit?(this: this.State, vnode: this.VNode): void;
    oncreate?(this: this.State, vnode: this.VNode): void;
    onbeforeremove?(this: this.State, vnode: this.VNode): Promise<any> | void;
    onremove?(this: this.State, vnode: this.VNode): void;
    onbeforeupdate?(this: this.State, vnode: this.VNode, old: this.VNode): boolean | void;
    onupdate?(this: this.State, vnode: this.VNode): void;
}

export interface Attributes extends _Lifecycle {
    type VNode: DOMVNode<this>;
    type Attrs: this;
    type ChildType: Children;
    type Element: * extends Element = HTMLElement;
}

// Children types
export type Child = ...;
export type Children<T extends Child = Child> = ...;

interface _Virtual<T extends string | C, C extends Attributes | Component> {
    tag: T;
    attrs: C.Attrs;
    state: C.State;
    children: C.ChildType;
    dom: C.Element;
}

interface DOMVNode<A extends Attributes> extends _Virtual<string, A> {}
interface ComponentVNode<C extends Component> extends _Virtual<C, C> {}

export interface ComponentAttributes<C extends Component> extends _Lifecycle {
    type VNode: ComponentVNode<C>;
}

export interface Component extends _Lifecycle {
    type Element: * extends Element = HTMLElement;
    type Attrs: ComponentAttributes<this>;
    type ChildType: Child;
    type VNode: ComponentVNode<this>;

    view(this: this.State, vnode: this.VNode): Children;
}

// Later:
export const Video = {
    type Element = HTMLVideoElement,
    type Attrs: ComponentAttributes<this> & {
        [P in keyof HTMLVideoElement]: T[P]
        playing: boolean
    },
    type State = this & {playing: boolean},

    oninit() { this.playing = false },
    oncreate(vnode) { if (this.playing) vnode.dom.play() },
    onupdate(vnode) {
        const playing = !!vnode.attrs.playing
        if (playing !== this.playing) {
            this.playing = playing
            if (playing) vnode.dom.play()
            else vnode.dom.pause()
        }
    },

    view(vnode) {
        const attrs = {}
        for (const key in Object.keys(vnode.attrs)) {
            if (key in HTMLVideoElement) attrs[key] = vnode.attrs[key]
        }
        return m("video", attrs)
    },
}

It's kind of hard to create a small, compelling example, because it only really starts showing its benefits with more complex types. Here's a few notes:


Also, here's some clarifications/corrections to the original proposal:

weswigham commented 7 years ago

You can do this today, [right now](http://www.typescriptlang.org/play/#src=%2F%2F%20Note%3A%20I've%20specifically%20removed%20the%20fragment-related%20types%20and%20some%20of%20the%0D%0A%2F%2F%20more%20specific%20constraints%20for%20simplicity%0D%0A%0D%0Aexport%20type%20VNode%20%3D%20DOMVNode%3CAttributes%3E%20%7C%20ComponentVNode%3CComponent%3E%3B%0D%0A%0D%0Ainterface%20_Lifecycle%20%7B%0D%0A%09_VNode%3F%3A%20VNode%3B%0D%0A%09_State%3F%3A%20this%3B%0D%0A%0D%0A%09oninit%3F(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D)%3A%20void%3B%0D%0A%09oncreate%3F(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D)%3A%20void%3B%0D%0A%09onbeforeremove%3F(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D)%3A%20Promise%3Cany%3E%20%7C%20void%3B%0D%0A%09onremove%3F(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D)%3A%20void%3B%0D%0A%09onbeforeupdate%3F(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D%2C%20old%3A%20this%5B%22_VNode%22%5D)%3A%20boolean%20%7C%20void%3B%0D%0A%09onupdate%3F(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D)%3A%20void%3B%0D%0A%7D%0D%0A%0D%0Aexport%20interface%20Attributes%20extends%20_Lifecycle%20%7B%0D%0A%09_VNode%3F%3A%20DOMVNode%3Cthis%3E%3B%0D%0A%09_Attrs%3F%3A%20this%3B%0D%0A%09_ChildType%3F%3A%20Children%3B%0D%0A%20%20%20%20_Element%3F%3A%20Element%3B%0D%0A%7D%0D%0A%0D%0A%2F%2F%20Children%20types%0D%0Atype%20Foo%3CT%3E%20%3D%20T%3B%0D%0Aexport%20type%20Child%20%3D%20%22A%22%20%7C%20%22B%22%20%7C%20%22C%22%3B%0D%0Aexport%20type%20Children%3CT%20extends%20Child%20%3D%20Child%3E%20%3D%20Foo%3CT%3E%3B%0D%0A%0D%0Ainterface%20_Virtual%3CT%20extends%20string%20%7C%20C%2C%20C%20extends%20Attributes%20%7C%20Component%3E%20%7B%0D%0A%09tag%3A%20T%3B%0D%0A%09attrs%3A%20C%5B%22_Attrs%22%5D%3B%0D%0A%09state%3A%20C%5B%22_State%22%5D%3B%0D%0A%09children%3A%20C%5B%22_ChildType%22%5D%3B%0D%0A%09dom%3A%20C%5B%22_Element%22%5D%3B%0D%0A%7D%0D%0A%0D%0Ainterface%20DOMVNode%3CA%20extends%20Attributes%3E%20extends%20_Virtual%3Cstring%2C%20A%3E%20%7B%7D%0D%0Ainterface%20ComponentVNode%3CC%20extends%20Component%3E%20extends%20_Virtual%3CC%2C%20C%3E%20%7B%7D%0D%0A%0D%0Aexport%20type%20ComponentAttributes%3CT%20extends%20Component%3E%20%3D%20%7B%0D%0A%20%20%20%20%5BP%20in%20keyof%20T%5B%22_Element%22%5D%5D%3A%20T%5B%22_Element%22%5D%5BP%5D%3B%0D%0A%7D%0D%0A%0D%0Aexport%20interface%20Component%20extends%20_Lifecycle%20%7B%0D%0A%09_Element%3F%3A%20HTMLElement%3B%0D%0A%09_Attrs%3F%3A%20ComponentAttributes%3Cthis%3E%3B%0D%0A%09_ChildType%3F%3A%20Child%3B%0D%0A%09_VNode%3F%3A%20ComponentVNode%3Cthis%3E%3B%0D%0A%0D%0A%09view(this%3A%20this%5B%22_State%22%5D%2C%20vnode%3A%20this%5B%22_VNode%22%5D)%3A%20Children%3B%0D%0A%7D%0D%0A%0D%0A%2F%2F%20Later%3A%0D%0Ainterface%20VideoComponent%20extends%20Component%20%7B%0D%0A%20%20%20%20_Element%3F%3A%20HTMLVideoElement%2C%0D%0A%20%20%20%20_Attrs%3F%3A%20ComponentAttributes%3Cthis%3E%20%26%20%7B%20playing%3A%20boolean%20%7D%3B%0D%0A%20%20%20%20_State%3F%3A%20this%20%26%20%7B%20playing%3A%20boolean%20%7D%3B%0D%0A%20%20%20%20_VNode%3F%3A%20ComponentVNode%3Cthis%3E%3B%0D%0A%7D%0D%0Aexport%20const%20Video%3A%20VideoComponent%20%3D%20%7B%0D%0A%20%20%20%20oninit()%20%7B%20this.playing%20%3D%20false%20%7D%2C%0D%0A%20%20%20%20oncreate(vnode)%20%7B%20if%20(this.playing)%20vnode.dom.play()%20%7D%2C%0D%0A%20%20%20%20onupdate(vnode)%20%7B%0D%0A%20%20%20%20%20%20%20%20const%20playing%20%3D%20!!vnode.attrs.playing%0D%0A%20%20%20%20%20%20%20%20if%20(playing%20!%3D%3D%20this.playing)%20%7B%0D%0A%20%20%20%20%20%20%20%20%20%20%20%20this.playing%20%3D%20playing%0D%0A%20%20%20%20%20%20%20%20%20%20%20%20if%20(playing)%20vnode.dom.play()%0D%0A%20%20%20%20%20%20%20%20%20%20%20%20else%20vnode.dom.pause()%0D%0A%20%20%20%20%20%20%20%20%7D%0D%0A%20%20%20%20%7D%2C%0D%0A%0D%0A%20%20%20%20view(vnode)%20%7B%0D%0A%20%20%20%20%20%20%20%20const%20attrs%20%3D%20%7B%7D%0D%0A%20%20%20%20%20%20%20%20for%20(const%20key%20in%20Object.keys(vnode.attrs))%20%7B%0D%0A%20%20%20%20%20%20%20%20%20%20%20%20if%20(key%20in%20HTMLVideoElement)%20attrs%5Bkey%5D%20%3D%20vnode.attrs%5Bkey%5D%0D%0A%20%20%20%20%20%20%20%20%7D%0D%0A%20%20%20%20%20%20%20%20return%20m(%22video%22%2C%20attrs)%0D%0A%20%20%20%20%7D%2C%0D%0A%7D%0D%0A), with indexed access types (to steal what you have).

It's not 100% the same, because there are some ergonomic differences and there were some type errors when I just changed the syntax, so it's a bit different because I'm unsure what you were going for. Hopefully you get the gist of it - indexed access on generics (like this) is related types. The only difference is that we assume that there must be value-space members for each 'psuedo-related type'.

masaeedu commented 7 years ago

It seems a more principled approach to this would be to support type families, which are type-level partial functions. These allow you to express arbitrary relations between types.

Instead of adding new syntax like InterfaceName.Type, you continue doing Type<InterfaceName> to retrieve an "associated type", but Type is a full-fat type-level function (i.e. supports overloading), which means Type<InterfaceName, "foo"> can resolve to a different thing from Type<Foo>, which can resolve to a different thing from Type<number>.

dead-claudia commented 7 years ago

@weswigham

The key differences are that:

  1. Types are required to be present, unlike optional indexed types like in your playground gist.
  2. The syntax opens the door for future generic associated types, which is where it gets far more powerful (as in, up there with OCaml's module functors), and would also add support for higher-kinded types. I just didn't include it in the original proposal, so it could start a bit smaller in scope.

To give an example of the second, using Fantasy Land's Monad type:

interface Monad {
    type T<A>: any;

    map<A, B>(this: this.T<A>, f: (a: A) => B): this.T<B>;
    ap<A, B>(this: this.T<A>, f: this.T<(a: A) => B>): this.T<B>;
    of<A>(a: A): this.T<A>;
    chain<A, B>(this: this.T<A>, f: (a: A) => this.T<B>): this.T<B>;
}
dead-claudia commented 7 years ago

@weswigham I also made a few tweaks to my proposal, which should help clarify things some. Try re-attempting it now with my revised example.

dead-claudia commented 7 years ago

@masaeedu Technically, this is looking for an implementation of type families; I'm just referring to the inner types (which are associated types) rather than the outer enclosing interface (type family).

Edit: to clarify, interfaces can be made polymorphic, and that's how you get a full implementation of type families. Consider this:

{-# LANGUAGE TypeFamilies #-}
class GMapKey k where
    data GMap k :: * -> *
    empty       :: GMap k v
    lookup      :: k -> GMap k v -> Maybe v
    insert      :: k -> v -> GMap k v -> GMap k v

Here's an equivalent implementation of this using my proposal:

interface Map<K> {
    type To<V>: *;

    create<V>(value: V): this.To<V>;
    empty<V>(): this.To<V>;
    lookup<V>(key: K, map: this.To<V>): V | void;
    insert<V>(key: K, value: V, map: this.To<V>): this.To<V>;
}
masaeedu commented 7 years ago

@isiahmeadows Ok, so it seems like the disagreement is mostly syntactic then. Do you think something like #17636 would be an alternative that would satisfy your requirements?

dead-claudia commented 7 years ago

@masaeedu That issue concerns a completely different problem: that of mapped types not having significant utility without some sort of filtering mechanism.

masaeedu commented 7 years ago

@isiahmeadows That issue provides type families in the sense of Haskell: type-level overloaded functions of an arbitrary arity of type arguments.

For your use case, you can have an entire family of indexed types described by FooAssociate<"thing1">, FooAssociate<"thing2">, FooAssociate<"thing3"> returning all the associated types of Foo. You'd declare it as type FooAssociate<T extends "thing1"> = ..., etc.

dead-claudia commented 7 years ago

@masaeedu Still, it's a hack that really misses the other problem I'm attempting to tackle with this issue: type encapsulation. Consider that in complex-to-model types, the number of generic parameters can get unwieldy enough that you find it easier to skip out on the types somewhat. Just as an example, imagine if this used your proposal instead of mine - feel free to try to port it to yours, and compare. You'll notice very quickly what I mean.

masaeedu commented 7 years ago

@isiahmeadows It isn't so much a hack as a strict generalization of associated types. Quoting the Haskell wiki page on type families where the GMapKey example originates:

Data families appear in two flavours: (1) they can be defined on the toplevel or (2) they can appear inside type classes (in which case they are known as associated types). The former is the more general variant, as it lacks the requirement for the type-indices to coincide with the class parameters.

At the very least we shouldn't implement the special cased sugar without the fundamental concept.

Just as an example, imagine if this used your proposal instead of mine - feel free to try to port it to yours, and compare.

Unfortunately there's too much stuff going on in that example for me to be able to understand what it represents (maybe the problem is I'm unfamiliar with Mithril). However, a crack at explaining how VNode would work from what little I understand:

type VNode<T extends Attributes> = DOMVNode<T>
type VNode<T extends ComponentAttributes<C>, C extends Component> = ComponentVNode<C>
type VNode<T extends Component> = ComponentVNode<T>

// Wherever you actually need it: VNode<this>, VNode<FooComponent>, VNode<typeof etc.>
masaeedu commented 7 years ago

The snippet above also illustrates an interesting design aspect: encapsulation breaks DRY. The second overload: type VNode<T extends ComponentAttributes<C>, C extends Component> = ComponentVNode<C>, is actually redundant with this formulation. It is already covered by type VNode<T extends Component> = ComponentVNode<T>.

Similarly, this approach does not permit associating arbitrary types with types for which you have no control over the declaration. I can't just associate types with Array unless I have control over Arrays declaration; I must instead make a subtype using extends and put the associated type there, then conscietiously use MyArray everywhere.

dead-claudia commented 7 years ago

@masaeedu Here's a few glitches in your assessment/translation:

  1. I was referring not just with vnodes, but especially components and their accepted attributes/children.
  2. My Vnode does not require any generics from the user's end, unlike yours.
  3. I was focusing on the user's end, not the implementor's, when applying DRY. Ease of implementation != ease of use - compare C's macro system to Rust's, for a good example of this.
masaeedu commented 7 years ago

I was referring not just with vnodes, but especially components and their accepted attributes/children.

The same principle applies to all the other embedded types; i.e. interface Attributes { type ChildType }; disappears, and interface Component ... { type ChildType: Child; } becomes type ChildType<T extends Component> = Child.

My Vnode does not require any generics from the user's end, unlike yours.

By "requiring generics" are you referring to the difference between VNode<this> vs this.VNode? Or is the user's end perhaps the Video type?

I was focusing on the user's end, not the implementor's, when applying DRY

Assuming Video is the user's end, it looks like:

// Associated types
type Element<T extends Video> = HTMLVideoElement
type Attrs<T extends Video>   = ComponentAttributes<T> & {
    [P in keyof HTMLVideoElement]: T[P]
    playing: boolean
}
type State<T extends Video> = T & { playing: boolean }

// Original declaration, as-is
export const Video = {
    oninit() { this.playing = false },
    oncreate(vnode) { if (this.playing) vnode.dom.play() },
    onupdate(vnode) {
        const playing = !!vnode.attrs.playing
        if (playing !== this.playing) {
            this.playing = playing
            if (playing) vnode.dom.play()
            else vnode.dom.pause()
        }
    },

    view(vnode) {
        const attrs = {}
        for (const key in Object.keys(vnode.attrs)) {
            if (key in HTMLVideoElement) attrs[key] = vnode.attrs[key]
        }
        return m("video", attrs)
    },
}

Which doesn't seem any harder for the user.

dead-claudia commented 7 years ago

@masaeedu Also, to clarify, my most immediate use case would be similar to my original example, but on serious steroids (components can be classes, factories, or components). There's also one key thing mine features that yours doesn't, which is highly relevant for my use case: it actually is part of the type's structure. Here's why that's helpful:

  1. When no value types are present, having types as part of the interface avoids the weak type restriction when assigning to them, inheriting from them, or using them as a constraint. In particular, that breaks your constraint overloads for interfaces with possibly no value properties.

    // Mine: neither of these are weak types
    interface One { type Foo: string; }
    interface Two { type Foo: number; }
    
    // Yours: both of these are weak types
    interface One {}
    interface Two {}
    type Foo<T extends One> = string;
    type Foo<T extends Two> = number;

    TypeScript 2.4's new weak type restrictions pretty much broke our types completely, which is why this is a concern.

  2. It is possible to define and access an associated type without having to import anything, by just declaring it. Yours requires using import {Foo} from "./mod-exporting-foo"; type Foo<T extends Whatever> = ...; to define the overload, and import {Foo} from "./mod-exporting-foo"; in the consumer's end to use it. Mine requires none of that at all, as it's little more than a special property.

By "requiring generics" are you referring to the difference between VNode<this> vs this.VNode? Or is the user's end perhaps the Video type?

I'm referring to type Vnode = DOMVNode<Attributes> | ComponentVNode<Component>;, where we don't need to use any to represent arbitrary vnodes; instead, we have effectively a local existential for when we need to access that property.

zpdDG4gta8XKpMCd commented 7 years ago
dead-claudia commented 7 years ago

@aleksey-bykov

  • why do you think classes (which are also values) are a vehicle for this new feature?

For the same reason I included interfaces and object literal types/values. The fact classes have a value is mostly irrelevant, and I was just aiming for completeness.

  • why would not we unify object and namespaces instead #8358 ?

Look at the last bullet of my rationale in the initial post. It's implied there as a logical future extension.

  • how is it all different from HKT #1213?

Because without generic associated types, it is theoretically only slightly more powerful than generics. But with that potential future extension, it does in fact offer a solution, although slightly boilerplatey.

Think of it this way: non-generic associated types are to simple generics as generic associated types are to higher kinded generics. They're complementary, not replacing.


Oh, and actually, if you combine generic associated types with this types, you effectively get path-dependent types similar to Scala's abstract types and Rust's associated types, which get powerful in a hurry (in fact, this is actually sufficient to render a type system Turing-complete, as both of theirs are. Granted, with indexed types, TypeScript's is already, too, so that's not saying much.)

dead-claudia commented 6 years ago

Just found another potential use case I didn't previously think of. Edit: Already addressed elsewhere.

dead-claudia commented 6 years ago

And also, now that I think about it, if you use an associated type without ever instantiating it, you could go one of two ways:

  1. A type error is generated - it must be concrete.
  2. It would be converted into an opaque type, thereby solving #202.
goodmind commented 6 years ago

any updates?

dead-claudia commented 6 years ago

@goodmind Based on the resolution here, I think they're awaiting more feedback, to see what other use cases exist. (I don't blame them - I just don't have the time to gather the various use cases this would feature.)

dead-claudia commented 6 years ago

@RyanCavanaugh @weswigham By the looks of it, it may also address the higher order kinds issue. I have an example gist implementing all of Fantasy Land's types using this proposal, complete with correct type constraints.

masaeedu commented 6 years ago

I'm kind of sad so much syntax was expended on implementing mapped types instead of doing type families in one or another form. Mapped types are just a special case of type level functors (currently implemented for type level maps and type level lists).

type Empty = {}
type With<K, V, O> = { [k: K]: V, ...O }

type Map<F, With<K, V, O>> = With<K, F<V>, Map<F, O>>
type Map<F, Empty>         = Empty

Given HKTs and type families, there's a whole ecosystem of type-level functors, foldables, monoids etc. just waiting to be exploited:

type Foldr<F, Z, []>        = Z
type Foldr<F, Z, [X, ...R]> = F<X, Foldr<F, Z, R>>

declare const merge:  <A>(...args: A) => Foldr<(&), {},    A>
declare const choose: <A>(...args: A) => Foldr<(|), never, A>
dead-claudia commented 6 years ago

@masaeedu That's about 50% off-topic - this has nothing to do with mapped types (hence the downvote). This proposal could enable higher order type functions, however, and that's the part I'll address.

That Foldr type, if this proposal is accepted, could be written this way (the ground work for the rest already exists):

type Foldr<F extends {type T<A, B>: any}, R, A = []> = {
    0: Z,
    1: ((h: H, ...t: T) => any) extends ((...a: A) => any)
        ? Foldr<F, F.T<H, R>, T>
        : never
}[A extends [] ? 0 : 1];

declare function merge<A>(...args: A): Foldr<{type T<A, B> = A & B}, {}, A>;
declare function choose<A>(...args: A): Foldr<{type T<A, B> = A | B}, never, A>;

But as a concrete example, merge and choose are already typeable without this, thanks to the work with tuples:

type Merge<A, R = {}> = {
    0: R,
    1: ((h: H, ...t: T) => any) extends ((...a: A) => any)
        ? Merge<T, R & H>
        : never
}[A extends [] ? 0 : 1];

type Choose<A, R = never> = {
    0: R,
    1: ((h: H, ...t: T) => any) extends ((...a: A) => any)
        ? Choose<T, R | H>
        : never
}[A extends [] ? 0 : 1];

declare function merge<A>(...args: A): Merge<A>;
declare function choose<A>(...args: A): Choose<A>;

If #26980 is also accepted, it could be made a little more readable and concise, although that doesn't add any theoretical power to anything:

type Foldr<F extends {type T<A, B>: any}, R, A = []> =
    ((h: H, ...t: T) => any) extends ((...a: A) => any)
        ? Foldr<F, F.T<H, Z>, T>
        : R;

declare function merge<A>(...args: A): Foldr<{type T<A, B> = A & B}, {}, A>;
declare function choose<A>(...args: A): Foldr<{type T<A, B> = A | B}, never, A>;

But I'll stop there, since even that's mostly OT to this.

masaeedu commented 6 years ago

I'm not sure you've actually grasped what you're responding to. Given that you can implement mapped types for any given type constructor using type families, and (by your own claim), this proposal "is looking for an implementation of type families", the ability to implement mapped types using type families is very relevant to this issue.

masaeedu commented 6 years ago

It's helpful to look at the corresponding prior art in Idris. There's no special syntax for "mapped types" in the language; instead, they just use the functor instance for type level lists with a type constructor of kind Type -> Type:

Idris> map (* 2) [1, 2]
[2, 4] : List Integer
Idris> map Maybe [Int, String]
[Maybe Int, Maybe String] : List Type

of course we might not want to go as far as Idris, but it is still useful to have functions at the type level to avoid having to bake lots of things into the language that can be expressed perfectly well in userland.

dead-claudia commented 6 years ago

@masaeedu

I'm not convinced type classes would work in something like TS, which has a (almost) purely structural type system. This is especially so considering how interfaces work. About the closest you could reasonably get is with symbol-typed interfaces, but even in plain JS, TC39 people have already run into cross-realm questions because of their inherent nominal typing issues (why Symbol.* symbols are identical cross-realm). Solutions attempting to work on the interface problem at the JS level is something that's currently being explored, but I'm not convinced type classes would do any good here. Plus, type classes would have to involve a type-influenced emit, something that the TS devs are mostly against in general. (The last exception I've seen was with decorators and reflect-metadata, added as a proposed standard from the TS + Angular devs.)

If you'd like to talk more on this subject, feel free to DM me on Twitter, and I'd happily follow up: https://twitter.com/isiahmeadows1

I just don't want to pollute this issue with noise when I'm waiting on the TS team to revisit it (a few new use cases have since come up that I've pinged them over).

masaeedu commented 6 years ago

A class instance is just an implementation of some functions parametrized over some types, so it isn't clear to me what sense they "wouldn't work" in. For example, an instance of the functor typeclass for arrays looks like this:

// :: (a -> b) -> [a] -> [b]
const map = f => xs => xs.map(f)

At the type level, it's just pushed up one level; a class instance is an implementation of some type level functions parametrized over some kinds. It would look like this (assuming we could abstract over unsaturated type constructors):

// :: (* -> *) -> [*] -> [*]
type Map<F, []> = []
type Map<F, [X, ...R]> = [F<X>, ...(Map<F, R>)]

(or whatever the equivalent thing is with your stuff up there).

Regardless, supposing we grant that "typeclasses don't work", the Map constructors above don't magically disappear. They're still a userland implementation of the same feature mapped types supply, which is illustrative of how type families + HKT could be used to subsume and extend the mapped types feature.

type classes would have to involve a type influenced emit Symbol.* reflect-metadata

All of these things are either irrelevant or untrue.

coyotte508 commented 4 years ago

I just want to bring more focus to a particular use case this issue (or the ability to have generic namespaces) would solve.

This is the current type definitions of a protocol for listing available commands with possible arguments / executing commands in a generic turn-based game engine:

interface BaseEngine<Player> {
  players: Player[];
  // ...
}

export type CommandStruct<
  Phase extends string,
  MoveName extends string,
  Player,
  Engine extends BaseEngine<Player> = BaseEngine<Player>,
  AvailableCommandData extends BaseCommandData<MoveName> = BaseCommandData<MoveName>,
  CommandData extends BaseCommandData<MoveName> = BaseCommandData<MoveName>,
> = {
  [phase in Phase]?: {
    [move in MoveName]?: {
      available?: (engine: Engine, player: Player) => _AvailableCommandHelper<MoveName, AvailableCommandData, move>,
      valid?: (move: _CommandHelper<MoveName, CommandData, move>, available: _CommandHelper<MoveName, AvailableCommandData, move>) => boolean,
      exec: (engine: Engine, player: Player, move: _Command<MoveName, CommandData, move>) => void
    }
  }
}

export type BaseCommandData<MoveName extends string> = {[key in MoveName]?: any};

export type AvailableCommands<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>> = {
  [move in MoveName]: _AvailableCommand<MoveName, AvailableCommandData, move>;
}

export type Commands<MoveName extends string, CommandData extends BaseCommandData<MoveName>> = {
  [move in MoveName]: _Command<MoveName, CommandData, move>;
}

export type AvailableCommand<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>> = AvailableCommands<MoveName, AvailableCommandData>[MoveName];
export type Command<MoveName extends string, CommandData extends BaseCommandData<MoveName>> = Commands<MoveName, CommandData>[MoveName];

export type MoveNameWithoutData<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>> = Exclude<MoveName, Exclude<_MoveNameWithData<MoveName, AvailableCommandData>[MoveName], never>>;
export type MoveNameWithData<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>> = Exclude<MoveName, MoveNameWithoutData<MoveName, AvailableCommandData>>;

type _CommandHelper<MoveName extends string, CommandData extends BaseCommandData<MoveName>, move extends MoveName> = move extends keyof CommandData ? CommandData[move] : never;
type _AvailableCommandHelper<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>, move extends MoveName> = move extends keyof AvailableCommandData ? AvailableCommandData[move] | AvailableCommandData[move][] | false : boolean;

type _AvailableCommand<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>, move extends MoveName> = _CommandHelper<MoveName, AvailableCommandData, move> extends never ? {move: move, player: number} : {move: move, player: number, data: _CommandHelper<MoveName, AvailableCommandData, move>};

type _Command<MoveName extends string, CommandData extends BaseCommandData<MoveName>, move extends MoveName> = _CommandHelper<MoveName, CommandData, move> extends never ? {move: move} : {move: move, data: _CommandHelper<MoveName, CommandData, move>};

type _MoveNameWithData<MoveName extends string, AvailableCommandData extends BaseCommandData<MoveName>> = {
  [key in MoveName]:_CommandHelper<MoveName, AvailableCommandData, key> extends never ? never : key
};

This is not very readable. With this suggestion, it could be made much more readable:

interface BaseEngine<Player> {
  players: Player[];
  // ...
}

type BaseCommandData<MoveName extends string> = {[key in MoveName]?: any};

type CommandInfo<MoveName extends string, CommandData extends BaseCommandData<MoveName>, AvailableCommandData extends BaseCommandData<MoveName>> {
  type AvailableCommand: AvailableCommands[MoveName];
  type Command: Commands[MoveName];

  type CommandStruct<Phase extends string, Player, Engine extends BaseEngine<Player> = BaseEngine<Player>>: {
    [phase in Phase]?: {
      [move in MoveName]?: {
        available?: (engine: Engine, player: Player) => MoveInfo<move>._AvailableCommandHelper,
        valid?: (move: move extends keyof CommandData ? CommandData[move] : never, available: move extends keyof AvailableCommandData ? AvailableCommandData[move] : never) => boolean,
        exec: (engine: Engine, player: Player, move: MoveInfo<move>.Command) => void
      }
    }
  }

  type MoveInfo<move extends MoveName>: {
     type AvailableCommand: move extends keyof AvailableCommandData ? {move: move, player: number, data: AvailableCommandData[move]} : {move: move, player: number};
     type Command: move extends keyof CommandData? {move: move, data: CommandData[move]} : {move: move};

    type _AvailableCommandHelper: move extends keyof AvailableCommandData ? AvailableCommandData[move] | AvailableCommandData[move][] | false : boolean;    
  }

  type AvailableCommands: {
    [move in MoveName]: MoveInfo<move>.AvailableCommand;
  }

  type Commands: {
    [move in MoveName]: MoveInfo<move>.Command;
  }

  type MoveNameWithoutData: Exclude<MoveName, Exclude<_MoveNameWithData[MoveName], never>>;
  type MoveNameWithData: Exclude<MoveName, MoveNameWithoutData>; 

  type _MoveNameWithData: {
    [move in MoveName]: move extends keyof AvailableCommandData ? move : never
  };
}

As an aside, the ability to selectively export nested types - or mark them private - would be great.

dead-claudia commented 4 years ago

Any status update? Could still totally use this myself for the reasons explained in the initial comment, too.

Sajjon commented 3 years ago

We NEED this.

iptq commented 3 years ago

Any alternative ways of achieving the same effect in Typescript today?

pakoito commented 2 years ago

I have one alternative that's not quite there but gets you 90% of the way

https://twitter.com/pacoworks/status/1463591505281568773/photo/1

I'm building an RPC, with a repo of functions, each with its own parameters and return:

// All the functions to be called, with a tuple of the parameters and the return
type RepoT = {
  'SomeKey': [number, string];
}

// Spread the functions into an object
export type Repo = { [k in keyof RepoT]: (params: RepoT[k][0]) => RepoT[k][1] };

// Union of all the keys
export type RepoIndex = keyof RepoT;
// Union of all the parameters
export type RepoParams<T extends RepoIndex> = Parameters<Repo[T]>[0];

How to create a consumer using associated-types-like:

// The relationship between index and parameters isn't enforced here
type CallerT = {
  index: RepoIndex;
  parameters: RepoParams<RepoIndex>;
};
// The exported type hides the constructor unless casted (from type-fest)
export type Caller = Opaque<CallerT, CallerT>;

// And in this exported constructor we enforce the index and parameters to have the same type
export const caller = <T extends RepoIndex>(index: T,  parameters: EffectFunParams<T>]): Consumer =>
  ({ index, parameters] }) as Consumer;

And usage example:

// Materialize the repo, it will typecheck every function and error on those missing 🎉
const repo: Repo = {
  'SomeKey': (number) => "123",
}

// Create one RPC call, type of the second argument is enforced 🎉
const funHandle = caller('SomeKey', 1);

// Look up the function in the repo and apply the parameters
repo[funHandle.index](funHandle.parameters)

If you add any bit more of complexity to the types (i.e. a repo of functions) it's possible that repo[funHandle.index] ends with never for parameters (params: never) => string, which you have to @ts-expect-error...and I'd like that to be fixed tbh.

julienrf commented 1 year ago

Here is another use case, if needed.

Consider a program that displays data. We can read various types of data (scalar numbers, 2D points, etc.). Here is the type of a component that is responsible for reading a specific type of data A and printing it:

type DataHandler<A> = {
  // parse the data that we are interested in from a given data source (containing arbitrary JSON)
  parse(source: any): A
  // show the data in the UI
  render(data: A): void
};

We can then have multiple of those data handlers:

const speedHandler: DataHandler<number> = {
  parse(source: any): number {
    return source.speed
  },
  render(data: number): void {
    console.log(`${data} km/h`);
  }
}

const userHandler: DataHandler<[string, number]> = {
  parse(source: any): [string, number] {
    return [source.name, source.age]
  },
  render(data: [string, number]): void {
    const [name, age] = data;
    console.log(`${name} is ${age} years old.`);
  }
}

Now, I want the UI to be able to use an arbitrary number of such data handlers, so I define a list of handlers:

const dataHandlers = [
  speedHandler,
  userHandler
];

Then we can try to parse and render our data with them as follows:

// arbitrary data source, just for the example
const source = {
  speed: 42,
  name: "Alice",
  age: 21
};
dataHandlers.forEach(dataHandler => {
  const data = dataHandler.parse(source);
  dataHandler.render(data);
})

Unfortunately, that code does not type-check. This is counter-intuitive because we just parsed data from a data handler and then rendered the data we just parsed using the same data handler. Given that the result type of the parse method of a data handler is always the same as the parameter type of the render method of that data handler, that code should type-check.

The error we get here is:

//  dataHandler.render(data);
//                     ^^^^
Argument of type 'number | [string, number]' is not assignable to parameter of type 'number & [string, number]'.
  Type 'number' is not assignable to type 'number & [string, number]'.
    Type 'number' is not assignable to type '[string, number]'.(2345)

What happened here is that the compiler inferred the type of dataHandlers to be an array of items of type DataHandler<number | [string, number]>. This is because we can not yet represent the type of a DataHandler handling some (unknown) type of data.

Currently, a workaround could be to use unknown as a type argument to get the code type-check:

const dataHandlers: Array<DataHandler<unknown>> = [
  speedHandler,
  userHandler
];

However, by doing so we weaken the type-checking guarantees: any arbitrary argument can then be passed to render. For instance, we could parse data with speedDataHandler and then render it with userDataHandler, which is wrong!

With this proposal, we could get strong typing guarantees:

abstract class DataHandler {
  abstract type Data: *;
  abstract parse(source: any): Data;
  abstract render(data: Data): void;
}

class SpeedDataHandler extends DataHandler {
  type Data: number;
  parse(source: any): number {
    return source.speed
  }
  render(data: number): void {
    console.log(`${data} km/h`);
  }
}
const speedDataHandler = new SpeedDataHandler();

class UserDataHandler extends DataHandler {
  type Data: [string, number];
  parse(source: any): [string, number] {
    return [source.name, source.age]
  }
  render(data: [string, number]): void {
    const [name, age] = data;
    console.log(`${name} is ${age} years old.`);
  }
}
const userDataHandler = new UserDataHandler();

const dataHandlers: Array<DataHandler> = [
  speedDataHandler,
  userDataHandler
];

const source = {
  speed: 42,
  name: "Alice",
  age: 21
};

dataHandlers.forEach(dataHandler => {
  const data = dataHandler.parse(source);
  dataHandler.render(data); // type-checks!
});

// and also, type errors are reported by the compiler:
const speedData = speedDataHandler.parse(source);
userDataHandler.render(speedData); // ERROR: type mismatch

I am not familiar with the evolution process of TypeScript or the roadmap. Would it be possible to know if this feature could be planned or not, and what are the challenges that need to be addressed? I am happy to help.