#Questions on Array inference discontinuities (JSDoc in .js)

22 messages · Page 1 of 1 (latest)

reef thistle
#

Why are Arrays declared inside Object literals inferred as Never and unable to evolve in Any like in all other cases? I'm thinking Dictionaries pre-seeded with initial values of differing objects.

  • 1.1. What's the most convenient/terse/idiomatic solution to typecheck existing JS files with those patterns, with the least refactoring as to keep the project JS-only proper? Is it solved with a different config, and if yes can it apply to floating files outside of a project or specific directory?
  • 1.2. Why do t4 and t5 work even though the argument to assign is also defined as an object literal?
  • 1.3. What are the downsides of /** @type {*} */ or /** @type {object} */ (which also works) over the inferred type of an open-ended object? Why is that not the default behavior?

Here I added types (generated by Document This) only on errors, and afterward all of these examples pass. No jsconfig.json, official VSCode plugin with default settings.

// @ts-check
"use strict";

const a = []
a.push(1)

/** @type {*} */
const t1 = {
    a: [],
}
t1.a.push(1)

/** @type {*} */
const t2 = {
    a: {
        b: [],
    },
}
t2.a.b.push(1)

const t3 = {}
t3.a = []
t3.a.push(1)

const t4 = {}
Object.assign( t4, {
    a: []
})
t4.a.push(1)

const t5 = {}
Object.assign( t5, {
    a: {
        b: []
    }
})
t5.a.b.push(1)

/** @type {*} */
const t6 = Object.assign( {}, {
    a: []
})
t6.a.push(1)

/** @type {*} */
const t7 = Object.assign( {}, {
    a: {
        b: []
    }
})
t7.a.b.push(1)
simple lark
#

@reef thistle Is /** @type {*} */ basically any?

simple lark
#

If so then yeah, most of these are going to work because you can access anything on any without type errors (but without safety either)

simple lark
#

The "usage-based-narrowing" behavior where TS allows you to do something like:

const x = [];
x.push(1); // x has narrowed to number[] here based on usage

is mostly a special case that TS applies to top-level declarations and not nested properties

#

Mostly you should either a) type the entire object, or b) cast the array to the expected type or c) declare the array separately

/** @type {{x: number[]}} */
const a = {
  x: []
}
a.x.push("foo"); // error as expected

const b = {
  x: /** @type {number[]} */ ([]) // the parens are required here
}
b.x.push("foo") // also error

/** @type { number[] } */
const x = [];
const c = { x };
c.x.push("foo") // also error
#

The b case is technically equivalent to const b = { x: [] as number[] } in normal TS syntax - it's technically an 'unsafe' assertion - but it's a fairly common usage anyway, I think

reef thistle
#

Thanks! I couldn't type the object because it closes it and I'm talking about dynamic collections but syntax B is reasonable.

I was more concerned with understand what's going on though, why does it need to be a special case when in al others it can evolve? It's can't be that it can't evolve because the object is finalized with the array's type as Never[] because unannotated objects are all open-ended, and in any cause it could've defaulted to Any[] just fine. There's many discussions in Github issues that talk about this they were in favor for sticking to Never[] before the default behavior was changed to allow evolving implicit Any[] in 2020, just not in this case.

  • 2.1. What's the catch with that?
  • 2.2. Does it mean there's still no way to run @ts-check on arbitrary .js files without any annotations hoping to only make use of the rest without getting false positive errors everywhere?

Also I have additional questions now:

#
  • 2.3. Why does Never[] evolves straight to Any[] if I push a number into it? Is there some config to get warnings if I try to push different primitive types in an unannotated array instead of just giving up on inference everywhere?
  • 2.4. Is there a tool like Erlang's Dialyzer that would let me extract inferred types? Both for ease of annotation (all JSDoc generators I've seen just look at the initial value for vars) and to quickly check which functions don't have a reasonable signatures and need work.
  • 2.5. What's the syntax for annotating the type of function parameters from object destructuring? The inline syntax works, but it makes a mess if I want a short declaration to stay inline. I need it by parameter name as to still be able to accept open objects, and even if I wanted to type a whole closed object in a single @param I can't figure out what name I should use (it works by position but that's just messy). In the context of (a, {foo} = {foo: [], bar: []}) => {}
  • 2.6. If I close an object either via typing or Object.preventExtensions it starts warning about accessing undeclared properties via dot notation, but it allows me just fine via bracket notation. Any config to disallow this, or is this better left to a linter?
  • 2.7. It's easy to bypass array type guards because Any can be added to anything (and is really easy to get from open ended objects). Any way to catch this? const a1 = /** @type {number[]} */ ([]); a1.push( /** @type {*} */ ('s') )
simple lark
# reef thistle Thanks! I couldn't type the object because it closes it and I'm talking about dy...

2.1. What's the catch with that?
I don't know for sure, but it'd probably add a lot of complexity to try to support evolving types on things other than simple top-level declarations - as it is, I think this is a fairly niche feature of TS, and adding a lot of complexity in order to be able to infer types in a few more places may not be worth it. (Someone recently linked https://github.com/microsoft/TypeScript/issues/16464#issuecomment-395106166 in #ts-discussion and this sort of mindset is probably relevant)

2.2. Does it mean there's still no way to run @ts-check on arbitrary .js files without any annotations hoping to only make use of the rest without getting false positive errors everywhere?
I would not expect to be able to @ts-check arbitrary JS files with strictness rules on, no. (noImplicitAny: false basically makes all of this go away) There are some features like this for being able to understand untyped JS code, but I think it's a secondary or tertiary feature of the language and there's just a lot of limits on how much you can infer without annotations.

(Personally my preference is to set strict: true, leave JS code unchecked (allowJs: true, checkJs: false), and migrate code ready for typechecking to .ts files with proper annotations.

2.3. Why does Never[] evolves straight to Any[] if I push a number into it? Is there some config to get warnings if I try to push different primitive types in an unannotated array instead of just giving up on inference everywhere?
It doesn't quite go 'straight to any[]', though the intellisense may make it look that way:

glossy shoalBOT
#
const x = []
    x.push(1);
//  ^? - const x: any[]
const y = x;
//        ^? - const x: number[]
simple lark
#

But yeah, if you really meant x to be a number[] array but you don't annotate it and push both a number and a string onto it then it'll be typed as Array<string | number>.

I don't think there's config for that, that's just a trade-off of TS supporting these sort of "infer what you meant by what you did" patterns (which is part of why I don't think this sort of behavior will be expanded much). The way you tell TS what you meant is by annotating types.

#

2.4. Is there a tool like Erlang's Dialyzer that would let me extract inferred types? Both for ease of annotation (all JSDoc generators I've seen just look at the initial value for vars) and to quickly check which functions don't have a reasonable signatures and need work.

I'm not aware of one; there are various tools for trying to auto-migrate JS code to TS code, but none seemed very sophisticated to me. (I prefer to do it by hand so I know the quality of the annotations)

#

2.5. What's the syntax for annotating the type of function parameters from object destructuring? The inline syntax works, but it makes a mess if I want a short declaration to stay inline. I need it by parameter name as to still be able to accept open objects, and even if I wanted to type a whole closed object in a single @param I can't figure out what name I should use (it works by position but that's just messy). In the context of (a, {foo} = {foo: [], bar: []}) => {}

Don't think there is one. TS can infer object destructured field types if there's a default value, but mostly you just need to annotate the whole object. (But types in TS generally allow extra fields so the object is still 'open' in that sense)

Here's an approach that works with JSDoc syntax:

function foo(/** @type {{x: number, y: number}} */{ x, y }) {
    
}
#

2.6. If I close an object either via typing or Object.preventExtensions it starts warning about accessing undeclared properties via dot notation, but it allows me just fine via bracket notation. Any config to disallow this, or is this better left to a linter?

That's not the default behavior with strict: true on, but it looks like noImplicitAny: false allows "" access more loosely than dotted access.

glossy shoalBOT
#
// @filename: input.js

function foo(/** @type {{x: number, y: number}} */{ x, y }) {
    x
//  ^? - (parameter) x: number
}
simple lark
#

2.7. It's easy to bypass array type guards because Any can be added to anything (and is really easy to get from open ended objects). Any way to catch this? const a1 = /** @type {number[]} */ ([]); a1.push( /** @type {*} */ ('s') )

Not really any, by design, basically turns the type system off. There are lint rules to complain about any usage, @typescript-eslint/no-unsafe-*, but mostly I think it's just 'if you want good typings, minimize any usage.

reef thistle
# simple lark > 2.1. What's the catch with that? I don't know for sure, but it'd probably add ...

I would not expect to be able to @ts-check arbitrary JS files with strictness rules on, no. (noImplicitAny: false basically makes all of this go away) There are some features like this for being able to understand untyped JS code, but I think it's a secondary or tertiary feature of the language and there's just a lot of limits on how much you can infer without annotations.
2.2. This was all with noImplicitAny: false, but after some more trial and error turns out strictNullChecks: false makes them default to Undefined[] instead of Never[] which allows them to evolve as usual. Even better though while [] inside objects is defined as Never[], Array() takes Any[], of course with the catch that it won't narrow to a specific type if I prepopulate it without switching back to literal syntax. Disabling Implicit strictNullChecks from the extension works for now, on my projects I can actually add a jsconfig.json along with a project-wide s/([=:]\s*)\[\]/\1/**@type {any[]}*/ ([])/g

Don't think there is one. TS can infer object destructured field types if there's a default value, but mostly you just need to annotate the whole object. (But types in TS generally allow extra fields so the object is still 'open' in that sense)
2.5. It accepts object with extra properties but I can't only type some of them and leave the others to inference, and again inline syntax is too messy. Admittedly not a big deal, nor using the position in block notation for any case that I would consider acceptable anyway.

#

_ _

That's not the default behavior with strict: true on, but it looks like noImplicitAny: false allows "" access more loosely than dotted access.
2.6. Great that at least noImplicitAny works. I really would've liked a separate option only for bracket notation, that's a reasonable default that doesn't cause anywhere nearly as many false positive as unannotated declarations and while I can see them implementing similar checks I think they're different enough use cases. Nothing else to do here I guess, right?

Additionally:

  • 3.1. Following (2.5), when trying to call a function with destructured parameters with a larger object I get "Object literal may only specify known properties" which is great for typos, but really unnecessary for extra properties when all of the required parameters are already present. Not big deal because it only happens for object literals, larger variables can be passed fine, and it can be bypassed by asserting to Any without losing any safety inside the function but still any way around this boilerplate?
  • 3.2. Any way to specify a different config for different files to gradually port codebases to noImplicitAny annotations but still without converting Javascript to Typescript? Even configuring the VSCode plugin instead of the project would work.
simple lark
#

Like:

type YourObj = {
  foo: string;
  bar: number;
  [key: string]: unknown
}

then you won't get excess key errors when assigning objects to that type. ... though this should be a rare thing, the normal thing in TS is to annotate all of your properties.

#

3.2. Any way to specify a different config for different files to gradually port codebases to noImplicitAny annotations but still without converting Javascript to Typescript? Even configuring the VSCode plugin instead of the project would work.
Not really, no. Project references can split a codebase into multiple chunks and each chunk can have its own tsconfig, including strictness rules, but the projects cannot circularly reference each other, so it's not really a good approach for 'incremental strictness'.

Again, this is a big reason I suggest strict: true, allowJs: true, checkJs: false - it gives a very good path for incremental migration to typesafe code.

#

I would really suggest moving to Typescript syntax unless you have a very strong reason not to: it's a much better migration story, and the JSDoc syntax is a lot more awkward and limited than proper TS code and nowadays (especially with node now being able to natively strip type annotations) there's very little reason to avoid it.