Open dead-claudia opened 7 years ago
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:
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.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:
any
.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.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:
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 Array
s 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:
Vnode
does not require any generics from the user's end, unlike yours.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:
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.
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>
vsthis.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:
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.
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:
// 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.
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.
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.