-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
--noUncheckedIndexedAccess #39560
--noUncheckedIndexedAccess #39560
Conversation
Congrats! |
@RyanCavanaugh Gave this a quick whirl on TS playground and I am seeing a bug. This is on 4.0-beta where the bug does not occur Note: Was testing to see if it would fix #36635 can you confirm if this PR should fix this issue? Thank you! Second Note: Installed this on my project locally in the hopes of testing if it fixed Array Destructuring and sadly it does not. Wasn't this PR supposed to also fix #36635 that was the reason it and other issues like it were closed and marked duplicate? Thoughts on the name: Perhaps consider |
Perhaps C/C++ uses pedantic but it comes from a different age, Rust's official eslint equivalent also uses pedantic which must be pretty modern. Some good ideas from asking the Internet:
|
@Skillz4Killz a few things
|
@typescript-bot pack this |
Hey @RyanCavanaugh, I've packed this into an installable tgz. You can install it for testing by referencing it in your
and then running There is also a playground for this build. |
Made an issue: microsoft/TypeScript-Website#766 |
Is it possible to enable this feature in tsconfig file? |
@awerlogus yes, it works the same way as other compiler options |
@RyanCavanaugh I upgraded my TypeScript version to dev.20200715 but vscode still shows me an error: |
@awerlogus This isn't in a dev build. You have to install the tgz the bot produced. |
@RyanCavanaugh It works now. Thanks. |
@RyanCavanaugh It looks very suspicious: declare function test<T extends Record<string, string>>(listener: (obj: T) => string): void
// var a: string | undefined
// Type 'string | undefined' is not assignable to type 'string'.
// Type 'undefined' is not assignable to type 'string'.ts(2322)
test<{ a: string }>(({ a }) => a) On hover the // function test<{
// a: string;
// }>(listener: (obj: {
// a: string;
// }) => string): void The call without of parameter destructuring works fine: // No errors
test<{ a: string }>((data) => data.a) |
Would it be reasonable to consider splitting these two changes into separate flags/PRs, one for the array index issue and one for the object indexing? I like both changes, but personally, I find the object access issue to be a minor issue that can be worked around pretty easily just by changing how objects are declared. (i.e. I'd argue that the array change, on its own, is probably reasonable for a Whereas, I do think the object changes may cause more pain for less gain, and so maybe that option belongs behind a In any case, I really hope this goes in in some form or another. I wrote an eslint rule, But it's always running into issues, due to this behavior of arrays. Code like |
The problem then is that something like declare const record: Partial<Record<string, Type>>;
const values = Object.values(record);
// typeof values -> (Type | undefined)[] So the "workaround" only works to express the type of an object which values can be set/defined to On the other hand, for me, the problem with arrays are far less problematic since I almost never use sparse arrays and almost always just iterate through all its elements (for-of, |
declare const record: Partial<Record<string, Type>>;
const values = Object.values(record);
// typeof values -> (Type | undefined)[] FWIW, I'd consider this behavior to be largely correct, as And, even in the case where it's not necessary (e.g. values are never removed, or always removed with And, yeah, random access into arrays is less common than iteration over them, which works well currently, but random access into arrays isn't a pattern I'd consider to be uncommon, either. And it'd be nice if they weren't inherently and unavoidably type unsafe. But I don't know if it matters which half of the PR is more important: if there's disagreement on that point, that's arguably more of a reason to split the rule not less. |
But that is exactly one of the points that can be solved with this PR. With declare const record: Record<string, Type>;
const values = Object.values(record);
// typeof values -> Type[]
const t1 = record.somePropertyThatMaybeDontExists;
// typeof t1 -> Type | undefined
record.stupidProperty = undefined; // Error: `undefined` is not assignable to `Type`. Quoting the PR description:
So the type |
I understand that. I'm not confused about what this PR does, and I'm not disagreeing that the PR's behavior is better than the existing workaround. I'm just saying, there is an existing workaround, and it's pretty good. For the array case, there is no workaround. There is (AFAIK) no way to get TS to prevent errors from accidentally accessing arrays out-of-bounds. |
Interesting feedback. I'd still be interested in seeing diffs or hearing about how this played out in practice in your projects. Regarding arrays vs maps, my impression was actually the opposite. Iterating an array correctly with a C-style loop is very easy to get right, so adding all the ceremony of postfix Conversely, if you have a map and some key you're indexing it with, where did that key come from? There aren't many answers that are clearly correct, especially compared to I will just reiterate that we're looking for feedback on having tried this out in practice - discussion about whether it should just be the default behavior, etc, is already extensively discussed in the linked issue, and should continue there if needed. 😞✋ |
Do you plan to fix parameter destructuring? |
@awerlogus I think I would fix that bug if and only if we ship this feature. Is this blocking you from trying it out? |
Not so much. I'm just used to looking for bugs one by one. |
const a = [1, 2] as const
// Got: 1 | undefined
// Expected: 1
const b = a[0] I think indexing tuples should be not affected by this feature. |
When reviewing some code in this repo, I noticed some potential bugs (or at least non-ideal error-handling cases) that arose from TypeScript's default behavior of assuming that all index operations yield valid values. That is, by default TS assumes that if `x: Record<string, Y>` then `x[anyString]` is `Y` rather than `Y | undefined`, and it assumes that all array access attempts are in bounds. noUncheckedIndexedAccess changes this so that you generally need to actually make sure that your indexes worked. (This is a bit awkward for arrays because it doesn't let you rely on `.length` checks, but it's not the end of the world.) More details on the flag are available at microsoft/TypeScript#39560 Here's an overview of the sorts of changes involved: - One of the issues tracked in #624 is that buildComposedSchema could fail with unclear errors if the input schema doesn't declare directives correctly; the code assumed that any `@join__owner` or `@join__type` directive application *must* have a non-null `graph` argument whose value matches one of the `join__Graph` enums. We probably should validate the definitions, but in the meantime this PR validates that the value we get out of the directive application is good, with explicit errors if not. - Our code uses a lot of record types like `{[x: string]: Y}`. noUncheckedIndexedAccess means we need to actually check that the value exists. (Another alternative would be to change to `Map` which does not have this issue.) I added a helper `getOrSetRecord` which allowed us to express a very common case more succinctly, which also led to less duplication of code. - In some cases where it is very clear from context that a lookup must succeed (eg, a length check), I just used `!`. - Some cases in composition explicitly checked to see if directives had arguments but not if it had at least one argument; adding a `?.[0]` helped here. - Many cases were fixed by just using `?.` syntax or saving a lookup in a variable before moving on. - Iteration over `Object.keys` could be replaced by `Object.values` or `Object.entries` to eliminate the index operation. - In some cases we could change the declaration of array types to be "non-empty array" declarations, which look like `[A, ...A[]]`. For example, the groupBy operation always makes the values of the returned map be non-empty arrays. I was also able to replace a bunch of the FieldSet types in the query planner with a NonemptyFieldSet type; there are other places where it might be good to improve our checks for nonempty FieldSets though. - Nested `function`s that close over values in their surrounding scope can't rely on narrowing of those values, but arrow functions assigned to a `const` that happen after the narrowing can, so in some cases I replaced nested functions with arrow functions (which generally involved moving it around too).
The fact that even the following still prints an error makes this unusable IMO. Indexed access should at least respect the narrowing provided by the function foo(obj: Record<string, number>, key: string) {
let value: number = 0;
if (key in obj) value = obj[key];
} Sure I can write It's a pity because the option did help me find some cases of potentially unbounded or at least not clearly bounded array access that I was able to refactor, but it's not worth wading through all the false positives. |
@ehoogeveen-medweb I think you should file a new issue reporting your example as a bug. It is clearly a bug to me. |
How it started:
How it's going:
😅 |
For real though, the point of the flag is to be as conservative as possible and CFA doesn't track where possible out-of-band mutations occur. If you had written function foo(obj: Record<string, number>, key: string) {
let value: number = 0;
if (key in obj) {
mut();
value = obj[key];
}
function mut() { key += "oops" }
} then the code is wrong. |
@ehoogeveen-medweb function isIn<Key extends string,T, Obj extends Record<string, T>>
(key: Key, obj: Obj): obj is Obj & Record<Key, T> {
return key in obj;
}
function foo<TString extends string>(obj: Record<string, number>, key: TString) {
let value: number = 0;
// ⌄-- Error here
if (key in obj) value = obj[key];
// No error
if (isIn(key, obj)) value = obj[key];
return value;
}
function foo2<TString extends string>(obj: Record<string, number>, key: TString) {
let value: number = 0;
if (key in obj) {
mut();
// ⌄-- Error
value = obj[key];
}
if (isIn(key, obj)) {
mut();
// No error, but we also can't mutate `key`, so we're safe
value = obj[key];
}
// ⌄-- Type 'string' is not assignable to type 'TString'.
function mut() { key += "oops" }
return value;
}
} |
I don't know anything about how the analysis works, but if it could be taught to distinguish the binary "nothing happened between the check and the access" from "something happened between the check and the access" I think that would already be good enough. Even if if (key in obj) {
value = obj[key];
} worked and if (key in obj) {
"do nothing";
value = obj[key];
} didn't, I think it would already make a big difference to the usability of this option. I could be mistaken of course. |
Ryan's counterexample is kind of a red herring though, because this also does not work: function foo(obj: Record<string, number>, key: string) {
const kk = key;
let value: number = 0;
if (kk in obj) {
value = obj[kk];
}
} This is the same pattern I use for narrowing in arrow functions, like (As an aside: sure would be nice to allow |
I had another crack this using the helper function in #39560 (comment) as a starting point and eventually managed to make it work. I ended up using the following helper functions. They're less safe than the original helper function because they don't guard against mutating the key, but they also don't require the calling function to be generic: // A declaration in a d.ts file is also enough.
const __detail: unique symbol = Symbol();
type RecordWithProp<V> = { [__detail]: V };
// Used instead of `key in obj`.
function objectHasProp<K extends number | string | symbol, V>(
obj: Record<K, V>,
key: K,
): obj is Record<K, V> & RecordWithProp<V> {
return key in obj;
}
// Used instead of `obj[key]`.
function objectGetProp<K extends number | string | symbol, V>(obj: Record<K, V> & RecordWithProp<V>, key: K) {
return obj[key] as V;
}
// Checks whether the array contains at least the given number of elements.
type ArrayAtleast<T, n extends number, U extends T[] = []> =
// Limit the instantiation depth to 10; we don't need it to be high.
U['length'] extends n | 10 ? [...U, ...T[]] : ArrayAtleast<T, n, [T, ...U]>;
function arrayAtleast<T, N extends number>(array: T[], atleastLength: N): array is ArrayAtleast<T, N> {
return array.length >= atleastLength;
} The main annoyance that I ran into: Because array types don't have any range information associated with them, you end up having to check or assert trivial array accesses like I used So to make this check more ergonomic (and not require these unsafe helper functions), I think TypeScript would have to:
But those both sound like big and tricky features to add to the language. Edit: Replaced ts-toolbelt's L.Repeat with a custom version that adds the array type itself at the end. With the intersection |
Pedantic Index SignaturesnoUncheckedIndexedAccessImplements #13778
This is a draft PR for the purpose of soliciting concrete feedback and discussion based on people trying this out in their code. An npm-installable version will be available soon (watch for the bot).
Given current limitations in CFA we don't have any good ideas on how to fix, we remain guardedly skeptical this feature will be usable in practice. We're quite open to feedback to the contrary and that's why this PR is up - so that people can try out some bits and see how usable it is. This flag is not scheduled for any current TS release and please don't bet your entire coding strategy on it being shipped in a release! Anyway, here's the PR text.
Introduction
Under the flag
--pedanticIndexSignatures
, the following changes occur:obj[index]
used in a read position will includeundefined
in its type, unlessindex
is a string literal or numeric literal with a previous narrowing in effectobj.prop
where no matching declared property namedprop
exists, but a string index signature does exist, will includeundefined
in its type, unless a previous narrowing onobj.prop
is in effectOther related behaviors are left unchanged:
type A = SomeType[number]
, retain their current meaning (undefined
is not added to this type)obj[index]
andobj.prop
forms retain their normal behaviorIn practice, it looks like this:
And like this:
Restrictions on narrowing on non-literal indices (see below) mean this feature is not as ergonomic as some other TypeScript behavior, so many common patterns may require extra work:
Better patterns include
for/of
:forEach
:Or stashing in a constant:
If you're targeting a newer environment or have an appropriate polyfill, you can also use
Object.entries
:Other Caveats & Discussion
Naming
Name is up for bikeshedding, subject to the following caveats:
--strict
family with its current behavorial limitations, so anything starting withstrict
is a no-go--strictIndexSignatures
settingArray
as part of the nameMore on Narrowing (or "Why doesn't my
for
loop just work?")A predicted FAQ is why a "normal" C-style
for
loop is not allowed:First, TS's control flow system doesn't track side effects in a sufficiently rich way to account for cases like:
so it would be incorrectly optimistic to assume this kind of mutation (either to
i
orarr
) hasn't occurred. In practice, in JS there's not much reason to usefor
instead offor/of
unless you're doing something with the index, and that "something" is usually math that could potentially result in an out-of-bounds index.Second, every narrowing is based on syntactic patterns, and adding the form
e[i]
to the list of things we narrow on incurred a 5-10% performance penalty. That doesn't sound like much in isolation, but if you and your ten best friends each got a new feature that came with a 5% performance penalty, you probably wouldn't like the fact that the next release of TypeScript was 70% slower than the last one.Design Point: Sparse Arrays
A sparse array is created in JavaScript whenever you write to an index that is not already in-bounds or one past the current end of the array:
JS behavior around sparse arrays is not the most predictable thing.
for / of
👉
for/of
treats a sparse array like an array with filled-inundefined
sforEach
👉
forEach
skips the sparse elements...
(spread)👉 When you spread a sparse array, you get a non-sparse version of it.
Given these behaviors, we have decided to just assume that sparse arrays don't exist in well-formed programs. They are extremely rare to encounter in practice, and handling them "correctly" will have some extremely negative side effects. In particular, TS doesn't have a way to change function signatures based on compiler settings, so
forEach
is always to be passing its element typeT
(notT | undefined
) to the callback. If sparse arrays are assumed to exist, then we need[...arr].forEach(x => x.toFixed())
to issue an error, and the only way to do that is to say that[...arr]
produces(number | undefined)[]
. This is going to be annoying as heck, because you can't use!
to convert a(T | undefined)[]
to aT[]
. Given how common patterns likeconst copy = [...someArr]
are for working with arrays, this does not seem palatable even for the most strict-enthusiast programmers.Design Point: What's the type of
T[number]
/T[string]
?A question when implementing this feature is given this type:
Should
Q
beboolean
(the type that's legal to write to the array) orboolean | undefined
(the type you'd get if you read from the array)?Inspecting this code:
It feels like the job of the checker in this mode is to say "
zzz
does a possibly out-of-bounds read and needs clarification". You could then write either of these instead:This has the very positive side effect that it means
T[number]
in a declaration file doesn't change meaning based on whether the flag is set, and maintains the identity that