Typescript: Allow object types to have property-like associated types

Created on 3 Aug 2017  ·  32Comments  ·  Source: microsoft/TypeScript

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

  • In complex structures like in virtual DOM components/nodes and in graphs, where there are numerous constraints to check, but they would get cumbersome in a hurry to force off onto the user.
  • In dynamic imports, where you never have the module namespace itself until runtime, yet you still want to be able to use types defined within it. Edit: Already addressed elsewhere.
  • In types where there's a lot of optional parameters, it'd be easier and more flexible to label which ones you're defining as part of the type.
  • In types where you want to specify a single optional parameter, and the required ordering requires you to specify some that you don't want to specify, that would get boilerplatey in a hurry. I've experienced this personally on multiple occasions.
  • It would allow fully typing namespaces and named module imports without hard-coded compiler handling.

Proposed Syntax/Semantics

  • Each interface and object type may have a number of associated types within it.
  • Associated types are checked for assignability like any other usual property, except they live in a different "namespace" from properties. In particular, the type must be assignable to the target's type.
  • To access an associated type, you use TypeName.Type, where Type is the name of an associated type.

    • For sugar and namespace compatibility, object.Type is equivalent to (typeof object).Type

  • To expect an interface with an associated type, you use Foo & {type Type: Value} or Foo with <Type: Value>.
  • To expect an object with an associated type, you use {type Type: Value}.
  • To declare an abstract associated type, you use type Type: * within the interface or object type.
  • To declare a non-abstract associated type, you use type Type: Default within the interface or object type.
  • To constrain an abstract associated type, you use type Type: * extends Super.
  • Associated types may be inherited from other interfaces and/or object types.
  • Namespaces' types are modified to include the exported types as associated types.
  • Classes may also define associated types, optionally with visibility modifiers.
  • Associated types may have defaults, in case they aren't defined or further constrained later.

    • Constraining an associated type with an existing default removes the default if and only if the existing constraint is not assignable to the new constraint. For example, {type Foo: string | number = string} & {type Foo: string | number} is assignable to {type Foo: string}, but {type Foo: string | number = string} & {type Foo: number} is not.

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

  • This is purely additive, making no observably incompatible changes beyond possibly different error messages.
  • This has no impact on any existing JavaScript-related proposal.

Other

  • It may slow down the type checker a little initially when namespaces are unified, but two optimization points are available, which would recover most of the perf hit, if not all:

    • Associated types could be stored in a per-interface type map.

    • The list of associated types could be initially stored as undefined to avoid generating a large number of empty arrays.

  • It should have little effect on editor tooling.
In Discussion Suggestion

Most helpful comment

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

All 32 comments

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

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

  • Associated types allow limited encapsulation of their outer types. You may notice that ComponentType is not actually generic. That's because you the user don't want to have to repeat what attributes it takes, what children it requires, etc., but you the implementor do care that your component receives the attributes and children you expect.
  • Associated types allow types to be considered as part of the interface, rather than part of the parent type. If you expect a type-like value (like Mithril's object components), you want to ensure that internally, your types remain correct. Each component and attribute map have a particular type of vnode they use to model their rendered state, so you want to ensure it remains true to their type.
  • Associated types allow some non-local passage that rely on this, offering a workaround for #6223 for some common cases, with a little boilerplate. You may notice that _Lifecycle uses associated types rather than parameters, but that's because otherwise, I'd require this to be available in the extends clause, like extends _Lifecycle<this, VnodeComp<this>>.

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

  • Associated types without constraints implicitly default to any.
  • Object literals may also include type aliases inline.
  • An object type with only associated types is not considered a weak type provided at least one of them has no default.
  • The outer interface, of course, can't extend any of its associated types.
  • The types are late-bound, almost like a restricted existential. (Scala uses abstract types and generics to cover most of the need for existentials.)
  • To set named types without Type & {type Foo: Bar}, you use Type with <Foo=Bar>, not Type<Foo=Bar> (to make it work with type aliases). It also requires that such an associated type exists, unlike Type & {type Foo: Bar}.
  • Type & {type Foo: Bar} and Type with <Foo=Bar> are equivalent, but Type & {type Foo: this.Bar} and Type with <Foo=this.Bar> are not.
  • Associated types may not be accessed from the interface itself, actually. They may only be accessed via the values.

You can do this today, right now, 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'.

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

@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>;
}

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

@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>;
}

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

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

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

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

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

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.

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

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.

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

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

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

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.

any updates?

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

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

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>

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

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.

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.

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

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.

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.

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

We NEED this.

Was this page helpful?
0 / 5 - 0 ratings