-
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 recursive generic type aliases #6230
Comments
While we had a long discussion about this on #3496, we closed that because the original issue was unblocked. We can certainly continue discussion a bit more. Is there a specific scenario you're interested in modeling here? |
As I recall, this happens because type aliases do not actually create a type. Furthermore, the type argument position causes a circularity because the only way to look up the type reference in the instantiation cache is to know the id of each type argument. It is pretty much a consequence of the caching mechanism for generics. |
Say you have a cyclic object graph of nodes defined like this: interface Node { id: number; children: Node[]; } When such graph gets serialized all cyclic references have to be encoded interface Reference { nodeId: number; } So ultimately the serializable object graph should be defined like this: interface NodePlain { id: number; childern: (Node | Reference)[]; } But practically during deserializing we want to replace all Reference interface Node { id: number; children: Node[]; } So instead of maintaining 2 interfaces (one for serializing, one for interface Draft { id: number; children: Child [] } Then we have: type Node = Draft ; function serialize (value: Node): NodePlain {} function deserialize (value: NodePlain): Node {}
|
More basic example type Json = null | string | number | boolean | Json [] | { [name: string]: Json } |
In your serialization example, I think you could get by with: interface Node extends Draft<Node> { }
interface NodePlain extends Draft<NodePlain | Reference> { } |
As long as there is only one sort of Draft, yes I can do it. In a non
trivial case I cannot:
type Node = OneDraft<Node> | AnotherDraft <Node>
|
Why can't you do interface Node extends OneDraft<Node>, AnotherDraft <Node> { } |
Because I am looking for a sum type (either or) not a product type (this
|
Oh sorry, you're right. I misread it. |
Well, the reason it happens is what I said before. Hopefully that can provide a clue about how to fix it. |
I have a slightly different case where the current work-around is not satisfying. Consider two types following the work-around pattern: type StringOrStringTree = (string | StringTree);
interface StringTree extends Array<StringOrStringTree> {}
type NumberOrNumberTree = (number | NumberTree);
interface NumberTree extends Array<NumberOrNumberTree>{} Now what I want to write is a function flatten(items) {
let result = [];
function flattenNode(node) {
if (Array.isArray(node)) {
node.forEach(flattenNode);
}
else {
result.push(node);
}
}
flattenNode(items);
return result;
} It is clear this would work correctly for both types but is there is no good typing for this function that allows both types and infers function flatten<T>(tree: T | this[]: T[] {
....
} would work where type StringOrStringTree = string | StringOrStringTree[]; where the This example requires a way to express the type (which the work-around gives me) and a way of expressing the type relation implied by the type for type inferencing (which the work-around doesn't give me). Here is some brain-storming around potential syntaxes: function flatten<T>(tree: T | this[]): T[];
function flatten<T>(tree: T | self[]): T[];
function flatten<T>(tree: T | union[]): T[];
function flatten<T, S = T | S[]>(tree: S): T[]; The last one above is the most general solution, allows expressing the type relation directly, but might make the implementation too difficult as it would allow any type relation expressible in using a type alias. The advantage of using a special symbol is it allows the type to be written as a stand-alone expression without need for a meta-symbol in situation where a meta-symbol would be awkward (e.g. |
👎 for anonymous recursive references. I don't see much of a use case for that, and it feels like a solution in search of a problem. It also reminds me too much of |
@isiahmeadows I don't have a problem with it being named only I couldn't come up with a good way to do it. The last example was the best I could come up with but it is, as you can see, fairly ugly and only works in the context of a type variables, not in a single expression. Suggestions? |
So far, the closest to feasible idea to come up is caching type aliases like how interfaces are already. Everything else so far that has been suggested along those lines have been declined AFAIK. // Now
interface JSONObject {
[key: string]: JSONValue;
}
interface JSONArray extends Array<JSONValue> {}
type JSONValue =
string |
number |
boolean |
JSONArray |
JSONObject;
// If type aliases are cached like interface types
type JSONValue =
string |
number |
boolean |
JSONValue[] |
{[key: string]: JSONValue}; I think the discrepancy of how type aliases and interfaces are cached is what's causing all these problems in the first place. Two code paths for similar concepts make it easy for this to happen. |
I agree but, my point was that this is incomplete as it doesn't provide a way to declare a function that is generic over recursive union types. For this I need to be able to express a recursive type relation as well as the type itself, hence my examples. |
Does my proposal not cover |
Sorry! I totally missed that. I should read more carefully. |
Bump, this could be a super helpful extension to the type system and allow for all sorts of cool things languages like Haskell already allow for, like the functional list definition: type List<T> = [T, List<T>] | [] |
Also, if this works (which it does): type List<T> = { a: [T, List<T>] } | [] and this doesn't: type List<T> = [T, List<T>] | [] Then this issue should probably be considered a bug and not a new feature. |
I feel it's a bug your first one doesn't trigger the cycle detector. In no other kind of situation without an interface in the way have I found recursively referenced type aliases not fail to check. |
But I do feel that caching type aliases similarly to how interfaces are already cached (or else, As for my idea, here's what should check, assuming
Here's what shouldn't check, following the same assumptions:
Generics don't affect this at all, and for purposes of detecting cycles, the generic parameters are generally irrelevant. The only exception is that generic bounds limits cannot recursively reference the type at the top level, like in As for when multiple type aliases get involved, it should work by simply reducing the type aliases until they no longer contain any other type aliases at the top level. If any cycles come up, they should be reported. |
@isiahmeadows I agree with the characterization of top-level versus non-top-level. That's a great way to put it. |
@JsonFreeman Thanks! 😄 I will point out that, as an odd edge case, it will allow |
I think it is good to allow |
I was implying it's okay. It's just odd at the conceptual level (for the average programmer). On a tangentially related note, where would be the best way to seek more input on a proposal? There doesn't seem to be any clear place(s) from what I've found. |
Is there any way to currently express a type like the one described by @Aleksey-Bykov above? type RawJSON =
| null
| string
| number
| { [key: string]: RawJSON }
| RawJSON[]; While this is a bit contrived, my real-world use case is in representing GraphQL type definitions, which this limitation makes impossible to correctly describe. Currently I define an arbitrary, fixed number of types for each "depth", with the final one resolving unsafely to |
As a followup, I have a few questions that might help move this along:
|
type RawJSON =
| null
| string
| number
| { [key: string]: RawJSON }
| RawJSONArray;
interface RawJSONArray extends Array<RawJSON> {} is the canonical example workaround. |
Assuming we had the ability to abstract over type constructors, one approach that would make this easier from an implementation point of view would be to disallow arbitrary recursion, and instead have a special
Then you can express any recursive type as an ordinary non-recursive type with an extra type parameter. The
This would make it easy for the type checker to distinguish recursive from non-recursive types, since all recursive types would be of the form |
@masaeedu Slight problem: you need some form of quantification plus some form of lambda-like abstraction for type-level operators. Typing that isn't exactly trivial, since you basically need a recursive type to define a type. (And by that point, it'd be easier to introduce dependent types.) I would like higher-order generic type support, but a fixpoint combinator really isn't on that list due to pragmatic concerns around type theory. |
@isiahmeadows I don't quite follow what you mean by "you basically need a recursive type to define a type". The proposal above doesn't require quantification, which is orthogonal in the lambda cube to type constructors, the only feature it does actually require. Given the ability to define non-recursive type constructors in user code, and a baked in fixpoint combinator for type constructors, you can express any recursive type constructor. I don't understand the aside about dependent types either, but once again you also don't need dependent types for the proposal above, since they are also an orthogonal feature in the lambda cube to type constructors. |
You're correct that the above doesn't require quantification, but that doesn't hold for the general case of the Y combinator, which supports arbitrary anonymous recursion, powerful enough it enables untyped lambda calculus to compute anything any Turing machine can. But it's worth noting that you don't need unbounded recursion to support that type - you could just use a variant of this, generalized to also include unions and intersections. The Turing-completeness of TS's type system could then be safely broken by applying a similar check with indexed types. And although they do appear open to that in theory, they concluded that for the time being, "we're not quite ready for this sort of thing". (They weren't fond of the fact it seemed to complicate consumer code, and you'd have to fix that by normalizing conditional types to nest the conditions' results as far as possible, something TS doesn't currently do.) |
Well no, not even the Y combinator at the type level requires quantification. Quantification or its absence is an orthogonal feature of a type system to the availability of and restrictions on type constructors.
It doesn't seem like Turing completeness is relevant here, given that the type system is already Turing complete. But while we're talking about irrelevant things: we don't need to talk about some other combinator to discuss this; introduction of Whether you allow:
or:
does not have any bearing on the properties of the type system, it's purely an implementation and user experience concern. There are workarounds that let us formulate things in a way where the |
sorry for crashing the party, but i am not sure where it is all going, i only wish i could do the following: type Expression = BinaryExpression<Expression> | UnaryExpression<Expression> | TrenaryExpression<Expresion> | /* ...other stuff */ am i asking for too much? |
@Aleksey-Bykov I don't think so. Before we started talking about Turing completeness and all kinds of other irrelevant tangents, I was suggesting that it would be easier to instead implement support for:
if perhaps less convenient to use. |
i've been pointed at a surprisingly simple answer, i am confused how i didn't see it before
|
I have a similar example:
The reason why I don't do something like:
Is that I want to be able to use these types for recursion schemes. In particular I'd like to define If we expand the definition of
which doesn't work, but if we expand the definition again we get:
which does work. I wonder if the type checker could be modified to auto expand aliases when it encounters recursive generic type aliases. |
@kenhowardpdx have you looked at this: #6230 (comment) |
@zpdDG4gta8XKpMCd the problem is that that formulation won't work with recursion schemes. Given a function:
where
by defining Here's the rest of the code:
Notice how The problem with:
is that it's always recursive which breaks recursion schemes. The code I posted above works in Flow which got me thinking, why is it possible to declare recursive data types in Flow without running into a stack overflow? I think the proposal I made to auto-expand type aliases could provide a way to do this in TypeScript. I started prototyping the idea this weekend, but I'm not familiar with TypeScript internals so It's slow going. I did make some progress though. I was able to expand a the type aliases. I still need to figure out how to create a copy of the expanded type and then substitute |
I came up with a different solution to make recursion schemes work in TypeScript. Instead of making the recursive type generic I define a helper type that replaces instances of a type (and arrays of the type) with a different type. This is used to define a generic type
Using these new types, here's the "evaluate" example from my previous post:
I think with a little more work a general version of |
Having to write
(found solution in this thread) Would be nice to have fixed :) |
@snebjorn I wouldn't say that these represent the same type:
actually means type NestedMap<T> = Map<string, T>
| Map<string, Map<string, T>>
| Map<string, Map<string, Map<string, T>>>
| ... with
is just the same as type NestedMap<T> = Map<string, T>
| Map<string, Map<string, T>> |
@zepatrik oh crap, you're right. Well then I guess this is the right place to ask for support for type NestedMap<T> = Map<string, NestedMap<T> | T> |
@snebjorn I presume you meant this as your solution? interface Node<T> extends Map<string, Node<T> | T> {} (This is a rare case where it's not quite so hacky to use the interface workaround.) |
I'm facing an issue which seem related to this: type BoundActions<
TState,
TActions extends Record<string, (...args : any[]) => (getState: () => TState, setState: (newState: Partial<TState>) => void, actions: BoundActions<TState,TActions>) => any>
> = {
[K in keyof TActions]: (
...args: Parameters<TActions[K]>
) => ReturnType<ReturnType<TActions[K]>>;
};
function createStoreSimplified<TState, TActions extends Record<string, (...args : any[]) => (getState: () => TState, setState: (newState: Partial<TState>) => void, actions: BoundActions<TState, TActions>) => any>>(initialState: TState, actions: TActions) : BoundActions<TState, TActions>{
let state = initialState;
const setState = (newPartialSate: Partial<TState>) => state = {...state, ...newPartialSate};
const getState = () => state;
const boundProps: BoundActions<TState, TActions> = {} as BoundActions<TState, TActions>;
for (const key in actions) {
if (actions.hasOwnProperty(key)) {
const element = actions[key];
boundProps[key] = (...args) => element(args)(getState, setState, boundProps);
}
}
return boundProps
} The following works type State = {value: number}
const initialState: State = { value: 0};
type ActionsType = typeof actions;
const actions = {
increment: (by : number = 1) => (getState: () => State, setState: (newState: Partial<State>) => void, actions: BoundActions<State, ActionsType>) => {
setState({value : getState().value + by});
},
doSomethingAndIncrement: () => (getState: () => State, setState: (newState: Partial<State>) => void, actions: BoundActions<State, ActionsType>) => {
actions.increment();
},
}
const store1 = createStoreSimplified(initialState, actions);
store1.increment(); But when relying on type inference it does not work anymore: const store2 = createStoreSimplified(initialState,{
increment: () => (get, set, actions) => {
set({value : get().value + 1});
},
doSomethingAndIncrement: () => (get, set, actions) => {
actions.increment();
},
});
store2.increment(); I fails with The full project is https://github.com/atlassian/react-sweet-state |
With #33050 the original example can now be written as: interface A<a> {
brand: 'a';
nested: a;
}
interface B<a> {
brand: 'b';
nested: a;
}
type D = A<D> | B<D> | string; |
I'm running into this limitation. The suggested approach: interface A<a> {
brand: 'a';
nested: a;
}
interface B<a> {
brand: 'b';
nested: a;
}
type D = A<D> | B<D> | string; ...pretty much works, though it leads to some verbosity in my code, because if you have a type like: type Wrapper<T> = FooWrapper<T> | BarWrapper<T> | BazWrapper<T> which you need for other purposes, you can't actually use it in a type like: type Node = Wrapper<Node> | string You have to write: type Wrapper<T> = FooWrapper<T> | BarWrapper<T> | BazWrapper<T>;
type Node = FooWrapper<Node> | BarWrapper<Node> | BazWrapper<Node> | string; The other issue I ran into with the interface workaround is that an interface can extend a tuple like |
The text was updated successfully, but these errors were encountered: