-
Notifications
You must be signed in to change notification settings - Fork 12.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow object types to have property-like associated types #17588
Comments
Can you show a few small examples of how the motivating cases get solved using this proposal? |
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:
|
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 |
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 |
The key differences are that:
To give an example of the second, using Fantasy Land's 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 |
@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
At the very least we shouldn't implement the special cased sugar without the fundamental concept.
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 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: 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 |
@masaeedu Here's a few glitches in your assessment/translation:
|
The same principle applies to all the other embedded types; i.e.
By "requiring generics" are you referring to the difference between
Assuming // 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:
I'm referring to |
|
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.
Look at the last bullet of my rationale in the initial post. It's implied there as a logical future extension.
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 |
|
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:
|
any updates? |
@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 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, 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
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. |
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 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:
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):
(or whatever the equivalent thing is with your stuff up there). Regardless, supposing we grant that "typeclasses don't work", the
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. |
Any alternative ways of achieving the same effect in Typescript today? |
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:
How to create a consumer using associated-types-like:
And usage example:
If you add any bit more of complexity to the types (i.e. a repo of functions) it's possible that |
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 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 The error we get here is:
What happened here is that the compiler inferred the type of Currently, a workaround could be to use 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 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. |
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 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.Proposed Syntax/Semantics
TypeName.Type
, whereType
is the name of an associated type.object.Type
is equivalent to(typeof object).Type
Foo & {type Type: Value}
orFoo with <Type: Value>
.{type Type: Value}
.type Type: *
within the interface or object type.type Type: Default
within the interface or object type.type Type: * extends Super
.{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:
Emit
This has no effect on the JavaScript emit as it is purely type-level.
Compatibility
Other
undefined
to avoid generating a large number of empty arrays.The text was updated successfully, but these errors were encountered: