Skip to content
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

Compiler inconsistently (but successfully?) resolves a type which indirectly references itself #43683

Closed
nettybun opened this issue Apr 15, 2021 · 5 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@nettybun
Copy link

nettybun commented Apr 15, 2021

Bug Report

🔎 Search Terms

  • "ts(7022)"
  • "is referenced directly or indirectly in its own initializer."
  • "initializer"

🕗 Version & Regression Information

Tested and occurs in TS Playground on 4.3.0-beta, 4.2.3, 4.1.5, 4.0.5.

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about types that reference themselves in their initializers.

⏯ Playground Link

Playground link with different test cases/attempted workarounds

💻 Code

I'm trying to port a vanilla JS reactive state library to TS. Signals are just getter/setter functions that store a value (type Signal<T> = { (): T }). Defining them with a function produces a signal that has a dynamic value.

This is code that a user would write (not the declare bit obviously; playground has full example):

declare function makeSignal<T extends {
  [K in keyof T]: (() => any) | T[K]
}>(obj: T): {
  [K in keyof T]: Signal<T[K] extends () => infer R ? R : T[K]>;
}

const data = makeSignal({
  text: '',
  count: 0,
  countPlusOne: () => data.count() + 1,
  countPlusTwo: () => data.countPlusOne() + 1,
});

// Throws:
// 'data' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. ts(7022)

Instead I tried using () => to add a level of indirection. This surprisingly works, but I don't think it should...

declare function makeSignal<T extends {
  [K in keyof T]: (() => any) | T[K]
}>(obj: T): {                           // ⬇⬇⬇ NEW `() =>`
  [K in keyof T]: Signal<T[K] extends () => () => infer R ? R : T[K]>;
}

const data = makeSignal({
  text: '',
  count: 0,
  countPlusOne: () => () => data.count() + 1,
  countPlusTwo: () => () => data.countPlusOne() + 1,
});

// Works 🎉
// const data: {
//     text: Signal<string>;
//     count: Signal<number>;
//     countPlusOne: Signal<number>;
//     countPlusTwo: Signal<number>;
// }

This works. I worry, is this an inconsistent resolution by the TS compiler? It isn't capable of resolving the shorter first example, but it can do the second example which uses infer R in the same way.

I'd like to use the first example, since it feels very odd to tell users "Please do () => () => X because... TS is like that"

🙁 Actual behavior

TS is able to resolve a more complicated variable type that more deeply references its own initializer, but not a more simpler/shallower reference.

🙂 Expected behavior

TS would be consistent in applying the 7022 error, either resolving the first example, or failing the second example.

@nettybun
Copy link
Author

I'm mostly interested in knowing if the double () => hack can be relied on - it feels fragile as if it's not supposed to work, and I don't want to tell developers to define all their callbacks as double nested functions only to one day have a new release of TS break type inference.

I'll try building TypeScript locally and seeing what ways there are for stepping through what's happening...

@andrewbranch
Copy link
Member

The compiler tends to resolve types as lazily as possible, and adding an extra function between a type and one of its members whose type is ultimately recursive could certainly defer work which, if done earlier, would have been a circularity. To answer your question, I think this is likely to continue working, but not outside the realm of possibility of breaking. I would certainly never tell someone “Please do () => () => X”; I would tell them to add a return type annotation on the self-referential functions.

@andrewbranch andrewbranch added the Question An issue which isn't directly actionable in code label Apr 15, 2021
@nettybun
Copy link
Author

Thanks for the response Andrew. I didn't realize you could add a return type on arrow functions that are in objects, that's a great answer. In the playground I linked I didn't do that and was unhappy with how verbose the alternatives were. Your version is certainly terse enough, so I'll suggest that design.

I imagine the lazy evaluation isn't a bug, even though it's a bit confusing.

@tjjfvi
Copy link
Contributor

tjjfvi commented Apr 16, 2021

The reason it was erroring here was the constraint; it was trying to check the countPlusTwo property against () => any, which forced it to evaluate the return type. When you added another () =>, it only had to check () => () => <defered> against () => any, which doesn't force evaluating <defered>.

@nettybun
Copy link
Author

That's amazing thanks! I've poked around the playground a bit and I feel like TS is at least a lot more consistent now with <defered> in mind. I'll close the issue.

@tjjfvi how were you able to see that the evaluation was defered? Is that something people need to run TS in a debugger for? I'd love to see how TS breaks down its error reporting to avoid this in the future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

3 participants