eslint-plugin-ts-data-forge 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/dist/plugin.d.mts +1 -1
  2. package/dist/plugin.d.mts.map +1 -1
  3. package/dist/plugin.mjs +1 -0
  4. package/dist/plugin.mjs.map +1 -1
  5. package/dist/rule-types.d.mts +3 -3
  6. package/dist/rule-types.d.mts.map +1 -1
  7. package/dist/rules/ast-utils.d.mts +59 -3
  8. package/dist/rules/ast-utils.d.mts.map +1 -1
  9. package/dist/rules/ast-utils.mjs +142 -31
  10. package/dist/rules/ast-utils.mjs.map +1 -1
  11. package/dist/rules/branded-number-types.mjs.map +1 -1
  12. package/dist/rules/import-utils.d.mts +1 -1
  13. package/dist/rules/import-utils.d.mts.map +1 -1
  14. package/dist/rules/import-utils.mjs +26 -13
  15. package/dist/rules/import-utils.mjs.map +1 -1
  16. package/dist/rules/no-side-effect-import.d.mts +1 -1
  17. package/dist/rules/no-side-effect-import.d.mts.map +1 -1
  18. package/dist/rules/no-unnecessary-type-guard.d.mts.map +1 -1
  19. package/dist/rules/no-unnecessary-type-guard.mjs +161 -101
  20. package/dist/rules/no-unnecessary-type-guard.mjs.map +1 -1
  21. package/dist/rules/prefer-arr-is-array.d.mts.map +1 -1
  22. package/dist/rules/prefer-arr-is-array.mjs +25 -11
  23. package/dist/rules/prefer-arr-is-array.mjs.map +1 -1
  24. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts +1 -1
  25. package/dist/rules/prefer-arr-is-bounded-length-array.d.mts.map +1 -1
  26. package/dist/rules/prefer-arr-is-bounded-length-array.mjs +11 -21
  27. package/dist/rules/prefer-arr-is-bounded-length-array.mjs.map +1 -1
  28. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts +1 -1
  29. package/dist/rules/prefer-arr-is-fixed-length-array.d.mts.map +1 -1
  30. package/dist/rules/prefer-arr-is-fixed-length-array.mjs +16 -28
  31. package/dist/rules/prefer-arr-is-fixed-length-array.mjs.map +1 -1
  32. package/dist/rules/prefer-arr-is-max-length-array.d.mts +1 -1
  33. package/dist/rules/prefer-arr-is-max-length-array.d.mts.map +1 -1
  34. package/dist/rules/prefer-arr-is-max-length-array.mjs +16 -24
  35. package/dist/rules/prefer-arr-is-max-length-array.mjs.map +1 -1
  36. package/dist/rules/prefer-arr-is-min-length-array.d.mts +1 -1
  37. package/dist/rules/prefer-arr-is-min-length-array.d.mts.map +1 -1
  38. package/dist/rules/prefer-arr-is-min-length-array.mjs +16 -24
  39. package/dist/rules/prefer-arr-is-min-length-array.mjs.map +1 -1
  40. package/dist/rules/prefer-arr-is-non-empty.d.mts.map +1 -1
  41. package/dist/rules/prefer-arr-is-non-empty.mjs +9 -22
  42. package/dist/rules/prefer-arr-is-non-empty.mjs.map +1 -1
  43. package/dist/rules/prefer-arr-scan.d.mts +6 -5
  44. package/dist/rules/prefer-arr-scan.d.mts.map +1 -1
  45. package/dist/rules/prefer-arr-scan.mjs +68 -31
  46. package/dist/rules/prefer-arr-scan.mjs.map +1 -1
  47. package/dist/rules/prefer-arr-sum.d.mts.map +1 -1
  48. package/dist/rules/prefer-arr-sum.mjs +57 -40
  49. package/dist/rules/prefer-arr-sum.mjs.map +1 -1
  50. package/dist/rules/prefer-arr-uniq.d.mts.map +1 -1
  51. package/dist/rules/prefer-arr-uniq.mjs +59 -30
  52. package/dist/rules/prefer-arr-uniq.mjs.map +1 -1
  53. package/dist/rules/prefer-as-int.d.mts.map +1 -1
  54. package/dist/rules/prefer-as-int.mjs +27 -24
  55. package/dist/rules/prefer-as-int.mjs.map +1 -1
  56. package/dist/rules/prefer-canonical-array-slicing.d.mts.map +1 -1
  57. package/dist/rules/prefer-canonical-array-slicing.mjs +59 -28
  58. package/dist/rules/prefer-canonical-array-slicing.mjs.map +1 -1
  59. package/dist/rules/prefer-canonical-length-cast.d.mts.map +1 -1
  60. package/dist/rules/prefer-canonical-length-cast.mjs +41 -16
  61. package/dist/rules/prefer-canonical-length-cast.mjs.map +1 -1
  62. package/dist/rules/prefer-canonical-length-guard.d.mts.map +1 -1
  63. package/dist/rules/prefer-canonical-length-guard.mjs +18 -8
  64. package/dist/rules/prefer-canonical-length-guard.mjs.map +1 -1
  65. package/dist/rules/prefer-comparison-over-nullish-guard.d.mts.map +1 -1
  66. package/dist/rules/prefer-comparison-over-nullish-guard.mjs +31 -6
  67. package/dist/rules/prefer-comparison-over-nullish-guard.mjs.map +1 -1
  68. package/dist/rules/prefer-is-non-null-object.d.mts.map +1 -1
  69. package/dist/rules/prefer-is-non-null-object.mjs +56 -40
  70. package/dist/rules/prefer-is-non-null-object.mjs.map +1 -1
  71. package/dist/rules/prefer-is-record-and-has-key.d.mts.map +1 -1
  72. package/dist/rules/prefer-is-record-and-has-key.mjs +18 -8
  73. package/dist/rules/prefer-is-record-and-has-key.mjs.map +1 -1
  74. package/dist/rules/prefer-num-safe-parse-float.d.mts +3 -1
  75. package/dist/rules/prefer-num-safe-parse-float.d.mts.map +1 -1
  76. package/dist/rules/prefer-num-safe-parse-float.mjs +27 -10
  77. package/dist/rules/prefer-num-safe-parse-float.mjs.map +1 -1
  78. package/dist/rules/prefer-num-safe-parse-int.d.mts.map +1 -1
  79. package/dist/rules/prefer-num-safe-parse-int.mjs +24 -11
  80. package/dist/rules/prefer-num-safe-parse-int.mjs.map +1 -1
  81. package/dist/rules/prefer-obj-over-entries-round-trip.d.mts.map +1 -1
  82. package/dist/rules/prefer-obj-over-entries-round-trip.mjs +22 -11
  83. package/dist/rules/prefer-obj-over-entries-round-trip.mjs.map +1 -1
  84. package/dist/rules/prefer-range-for-loop.d.mts.map +1 -1
  85. package/dist/rules/prefer-range-for-loop.mjs +15 -8
  86. package/dist/rules/prefer-range-for-loop.mjs.map +1 -1
  87. package/dist/types.d.mts +2 -2
  88. package/dist/types.d.mts.map +1 -1
  89. package/package.json +25 -23
  90. package/src/plugin.mts +5 -4
  91. package/src/plugin.test.mts +0 -13
  92. package/src/rule-types.mts +3 -3
  93. package/src/rules/ast-utils.mts +192 -37
  94. package/src/rules/branded-number-types.mts +1 -1
  95. package/src/rules/import-utils.mts +41 -17
  96. package/src/rules/no-side-effect-import.mts +1 -1
  97. package/src/rules/no-unnecessary-type-guard.mts +249 -148
  98. package/src/rules/no-unnecessary-type-guard.test.mts +100 -0
  99. package/src/rules/prefer-arr-is-array.mts +32 -13
  100. package/src/rules/prefer-arr-is-array.test.mts +40 -0
  101. package/src/rules/prefer-arr-is-bounded-length-array.mts +22 -26
  102. package/src/rules/prefer-arr-is-bounded-length-array.test.mts +68 -0
  103. package/src/rules/prefer-arr-is-fixed-length-array.mts +26 -38
  104. package/src/rules/prefer-arr-is-fixed-length-array.test.mts +63 -0
  105. package/src/rules/prefer-arr-is-max-length-array.mts +26 -27
  106. package/src/rules/prefer-arr-is-max-length-array.test.mts +67 -0
  107. package/src/rules/prefer-arr-is-min-length-array.mts +26 -27
  108. package/src/rules/prefer-arr-is-min-length-array.test.mts +70 -0
  109. package/src/rules/prefer-arr-is-non-empty.mts +19 -24
  110. package/src/rules/prefer-arr-is-non-empty.test.mts +79 -0
  111. package/src/rules/prefer-arr-scan.mts +107 -37
  112. package/src/rules/prefer-arr-scan.test.mts +149 -0
  113. package/src/rules/prefer-arr-sum.mts +87 -50
  114. package/src/rules/prefer-arr-sum.test.mts +97 -0
  115. package/src/rules/prefer-arr-uniq.mts +83 -33
  116. package/src/rules/prefer-arr-uniq.test.mts +71 -0
  117. package/src/rules/prefer-as-int.mts +38 -38
  118. package/src/rules/prefer-as-int.test.mts +51 -0
  119. package/src/rules/prefer-canonical-array-slicing.mts +84 -29
  120. package/src/rules/prefer-canonical-array-slicing.test.mts +92 -0
  121. package/src/rules/prefer-canonical-length-cast.mts +56 -18
  122. package/src/rules/prefer-canonical-length-cast.test.mts +111 -0
  123. package/src/rules/prefer-canonical-length-guard.mts +25 -11
  124. package/src/rules/prefer-canonical-length-guard.test.mts +99 -0
  125. package/src/rules/prefer-comparison-over-nullish-guard.mts +42 -9
  126. package/src/rules/prefer-comparison-over-nullish-guard.test.mts +136 -0
  127. package/src/rules/prefer-is-non-null-object.mts +57 -33
  128. package/src/rules/prefer-is-non-null-object.test.mts +42 -0
  129. package/src/rules/prefer-is-record-and-has-key.mts +27 -12
  130. package/src/rules/prefer-is-record-and-has-key.test.mts +34 -0
  131. package/src/rules/prefer-num-safe-parse-float.mts +37 -13
  132. package/src/rules/prefer-num-safe-parse-float.test.mts +79 -0
  133. package/src/rules/prefer-num-safe-parse-int.mts +41 -15
  134. package/src/rules/prefer-num-safe-parse-int.test.mts +64 -0
  135. package/src/rules/prefer-obj-over-entries-round-trip.mts +34 -12
  136. package/src/rules/prefer-range-for-loop.mts +21 -9
  137. package/src/rules/prefer-range-for-loop.test.mts +75 -0
  138. package/src/rules/rules.mts +1 -1
  139. package/src/types.mts +2 -2
@@ -4,8 +4,9 @@ import {
4
4
  type TSESLint,
5
5
  type TSESTree,
6
6
  } from '@typescript-eslint/utils';
7
- import { type DeepReadonly, type ReadonlyRecord } from 'ts-type-forge';
7
+ import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
8
8
  import * as ts from 'typescript';
9
+ import { typeWrapperLayers } from './ast-utils.mjs';
9
10
  import {
10
11
  buildCalleeResolver,
11
12
  buildImportFixes,
@@ -156,7 +157,9 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
156
157
  const strictNullChecks =
157
158
  compilerOptions.strictNullChecks ?? compilerOptions.strict ?? false;
158
159
 
159
- if (!strictNullChecks) return {};
160
+ if (!strictNullChecks) {
161
+ return {};
162
+ }
160
163
 
161
164
  const checker = parserServices.program.getTypeChecker();
162
165
 
@@ -249,7 +252,9 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
249
252
 
250
253
  return {
251
254
  CallExpression: (node) => {
252
- if (node.arguments.length !== 1) return;
255
+ if (node.arguments.length !== 1) {
256
+ return;
257
+ }
253
258
 
254
259
  const argument = node.arguments[0];
255
260
 
@@ -262,140 +267,57 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
262
267
 
263
268
  const resolved = resolveGuard(node.callee);
264
269
 
265
- if (resolved === undefined) return;
270
+ if (resolved === undefined) {
271
+ return;
272
+ }
266
273
 
267
274
  const { canonicalName, propertyNode, isNamespace } = resolved;
268
275
 
269
- if (ignored.has(canonicalName)) return;
276
+ if (ignored.has(canonicalName)) {
277
+ return;
278
+ }
270
279
 
271
280
  const spec = GUARD_SPECS[canonicalName];
272
281
 
273
- if (spec === undefined) return;
274
-
275
- const argTsNode = parserServices.esTreeNodeToTSNodeMap.get(argument);
276
-
277
- const argType = checker.getTypeAtLocation(argTsNode);
278
-
279
- const parts = collectUnionParts(argType);
280
-
281
- if (parts === undefined) return; // opaque / generic → bail conservatively
282
-
283
- const inputAtoms = new Set(parts.map(classifyAtom));
284
-
285
- const hasMemberOutsideTarget = (
286
- atoms: readonly AtomKey[],
287
- ): boolean => {
288
- const targetSet = new Set<Atom>(atoms);
289
-
290
- return parts.some((p) => !targetSet.has(classifyAtom(p)));
291
- };
292
-
293
- switch (spec.kind) {
294
- case 'excludeFrom': {
295
- const removable = spec.atoms.filter((a) => inputAtoms.has(a));
296
-
297
- if (removable.length === 0) {
298
- // Nothing to exclude → the guard always holds.
299
- reportConstant(node, canonicalName, true, argument);
300
- } else if (!hasMemberOutsideTarget(spec.atoms)) {
301
- // Every member is excluded → the guard never holds.
302
- reportConstant(node, canonicalName, false, argument);
303
- } else if (removable.length < spec.atoms.length) {
304
- const replacement = spec.replacements?.[joinAtoms(removable)];
305
-
306
- if (replacement !== undefined) {
307
- reportReplace(
308
- node,
309
- canonicalName,
310
- replacement,
311
- isNamespace,
312
- propertyNode,
313
- );
314
- }
315
- }
316
-
317
- break;
318
- }
319
-
320
- case 'narrowTo': {
321
- const present = spec.atoms.filter((a) => inputAtoms.has(a));
322
-
323
- if (present.length === 0) {
324
- // No member is in the target set → the guard never holds.
325
- reportConstant(node, canonicalName, false, argument);
326
- } else if (!hasMemberOutsideTarget(spec.atoms)) {
327
- // Input ⊆ target → the guard always holds and narrows nothing.
328
- reportConstant(node, canonicalName, true, argument);
329
- } else if (present.length < spec.atoms.length) {
330
- const replacement = spec.replacements?.[joinAtoms(present)];
331
-
332
- if (replacement !== undefined) {
333
- reportReplace(
334
- node,
335
- canonicalName,
336
- replacement,
337
- isNamespace,
338
- propertyNode,
339
- );
340
- }
341
- }
342
-
343
- break;
344
- }
345
-
346
- case 'record': {
347
- if (parts.every((p) => isGuaranteedRecord(p, checker))) {
348
- // Every member already satisfies `UnknownRecord`.
349
- reportConstant(node, canonicalName, true, argument);
350
- } else if (
351
- parts.every((p) => isGuaranteedNotRecord(p, checker))
352
- ) {
353
- // No member can ever be a non-null, non-array object.
354
- reportConstant(node, canonicalName, false, argument);
355
- }
356
-
357
- break;
358
- }
359
-
360
- case 'nonEmptyString': {
361
- const hasNullish =
362
- inputAtoms.has('undefined') || inputAtoms.has('null');
363
-
364
- const nonNullishParts = parts.filter((p) => {
365
- const atom = classifyAtom(p);
366
-
367
- return atom !== 'undefined' && atom !== 'null';
368
- });
369
-
370
- if (!nonNullishParts.some(couldBeNonEmptyString)) {
371
- // No member can be a non-empty string → the guard never holds.
372
- reportConstant(node, canonicalName, false, argument);
373
-
374
- break;
375
- }
376
-
377
- if (
378
- nonNullishParts.some(
379
- (p) => !isGuaranteedNonEmptyString(p, checker),
380
- )
381
- ) {
382
- break; // still does real string/empty work
383
- }
384
-
385
- if (hasNullish) {
386
- reportReplace(
387
- node,
388
- canonicalName,
389
- 'isNonNullish',
390
- isNamespace,
391
- propertyNode,
392
- );
393
- } else {
394
- reportConstant(node, canonicalName, true, argument);
395
- }
396
-
397
- break;
398
- }
282
+ if (spec === undefined) {
283
+ return;
284
+ }
285
+
286
+ // A conclusion drawn from the argument's type has to hold at every
287
+ // layer of type wrappers around it (see typeWrapperLayers):
288
+ // `isNull(x as string | null)` is the author saying `x` may be
289
+ // `null`, and `isNullish(x as string | undefined)` still sees the
290
+ // `null` that `x` may hold.
291
+ const layerTypes = typeWrapperLayers(argument).map((layer) =>
292
+ checker.getTypeAtLocation(
293
+ parserServices.esTreeNodeToTSNodeMap.get(layer),
294
+ ),
295
+ );
296
+
297
+ const verdict = layerTypes
298
+ .map((type) => verdictOf(spec, type, checker))
299
+ .find(
300
+ (candidate): candidate is Verdict =>
301
+ candidate !== undefined &&
302
+ layerTypes.every((type) =>
303
+ holdsOn(candidate, spec, type, checker),
304
+ ),
305
+ );
306
+
307
+ if (verdict === undefined) {
308
+ return;
309
+ }
310
+
311
+ if (verdict.kind === 'constant') {
312
+ reportConstant(node, canonicalName, verdict.value, argument);
313
+ } else {
314
+ reportReplace(
315
+ node,
316
+ canonicalName,
317
+ verdict.replacement,
318
+ isNamespace,
319
+ propertyNode,
320
+ );
399
321
  }
400
322
  },
401
323
  };
@@ -403,6 +325,158 @@ export const noUnnecessaryTypeGuard: TSESLint.RuleModule<MessageIds, Options> =
403
325
  defaultOptions: [{}],
404
326
  } as const;
405
327
 
328
+ /** What the rule concludes about a guard call from one type of its argument. */
329
+ type Verdict = DeepReadonly<
330
+ | { kind: 'constant'; value: boolean }
331
+ | { kind: 'replace'; replacement: string }
332
+ >;
333
+
334
+ /**
335
+ * What a guard with `spec` does on an argument of `type`: always the same
336
+ * boolean, the same as a narrower guard, or nothing the rule can say.
337
+ */
338
+ const verdictOf = (
339
+ spec: GuardSpec,
340
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
341
+ type: ts.Type,
342
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
343
+ checker: ts.TypeChecker,
344
+ ): Verdict | undefined => {
345
+ const parts = collectUnionParts(type);
346
+
347
+ if (parts === undefined) {
348
+ return undefined; // opaque / generic → bail conservatively
349
+ }
350
+
351
+ const inputAtoms = new Set(parts.map(classifyAtom));
352
+
353
+ const hasMemberOutsideTarget = (atoms: readonly AtomKey[]): boolean => {
354
+ const targetSet = new Set<Atom>(atoms);
355
+
356
+ return parts.some((p) => !targetSet.has(classifyAtom(p)));
357
+ };
358
+
359
+ switch (spec.kind) {
360
+ case 'excludeFrom': {
361
+ const removable = spec.atoms.filter((a) => inputAtoms.has(a));
362
+
363
+ if (removable.length === 0) {
364
+ // Nothing to exclude → the guard always holds.
365
+ return { kind: 'constant', value: true } as const;
366
+ }
367
+
368
+ if (!hasMemberOutsideTarget(spec.atoms)) {
369
+ // Every member is excluded → the guard never holds.
370
+ return { kind: 'constant', value: false } as const;
371
+ }
372
+
373
+ return replacementVerdict(
374
+ removable.length < spec.atoms.length
375
+ ? spec.replacements?.[joinAtoms(removable)]
376
+ : undefined,
377
+ );
378
+ }
379
+
380
+ case 'narrowTo': {
381
+ const present = spec.atoms.filter((a) => inputAtoms.has(a));
382
+
383
+ if (present.length === 0) {
384
+ // No member is in the target set → the guard never holds.
385
+ return { kind: 'constant', value: false } as const;
386
+ }
387
+
388
+ if (!hasMemberOutsideTarget(spec.atoms)) {
389
+ // Input ⊆ target → the guard always holds and narrows nothing.
390
+ return { kind: 'constant', value: true } as const;
391
+ }
392
+
393
+ return replacementVerdict(
394
+ present.length < spec.atoms.length
395
+ ? spec.replacements?.[joinAtoms(present)]
396
+ : undefined,
397
+ );
398
+ }
399
+
400
+ case 'record':
401
+ // Every member already satisfies `UnknownRecord`, or none can ever be
402
+ // a non-null, non-array object.
403
+ return parts.every((p) => isGuaranteedRecord(p, checker))
404
+ ? ({ kind: 'constant', value: true } as const)
405
+ : parts.every((p) => isGuaranteedNotRecord(p, checker))
406
+ ? ({ kind: 'constant', value: false } as const)
407
+ : undefined;
408
+
409
+ case 'nonEmptyString': {
410
+ const nonNullishParts = parts.filter((p) => {
411
+ const atom = classifyAtom(p);
412
+
413
+ return atom !== 'undefined' && atom !== 'null';
414
+ });
415
+
416
+ if (!nonNullishParts.some(couldBeNonEmptyString)) {
417
+ // No member can be a non-empty string → the guard never holds.
418
+ return { kind: 'constant', value: false } as const;
419
+ }
420
+
421
+ if (
422
+ nonNullishParts.some((p) => !isGuaranteedNonEmptyString(p, checker))
423
+ ) {
424
+ return undefined; // still does real string/empty work
425
+ }
426
+
427
+ return inputAtoms.has('undefined') || inputAtoms.has('null')
428
+ ? ({ kind: 'replace', replacement: 'isNonNullish' } as const)
429
+ : ({ kind: 'constant', value: true } as const);
430
+ }
431
+ }
432
+ };
433
+
434
+ const replacementVerdict = (
435
+ replacement: string | undefined,
436
+ ): Verdict | undefined =>
437
+ replacement === undefined
438
+ ? undefined
439
+ : ({ kind: 'replace', replacement } as const);
440
+
441
+ /**
442
+ * Whether `verdict`, drawn from one layer's type, also holds for `type`. A
443
+ * constant has to come out the same. A replacement holds where the rule would
444
+ * suggest the same one, or where the guard and its replacement are the same
445
+ * constant: under `x!` on a `string | null`, `isNonNullish` and `isNotNull`
446
+ * are both always `true` on `string`.
447
+ */
448
+ const holdsOn = (
449
+ verdict: Verdict,
450
+ spec: GuardSpec,
451
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
452
+ type: ts.Type,
453
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
454
+ checker: ts.TypeChecker,
455
+ ): boolean => {
456
+ const onType = verdictOf(spec, type, checker);
457
+
458
+ if (verdict.kind === 'constant') {
459
+ return onType?.kind === 'constant' && onType.value === verdict.value;
460
+ }
461
+
462
+ if (onType?.kind === 'replace') {
463
+ return onType.replacement === verdict.replacement;
464
+ }
465
+
466
+ const replacementSpec = GUARD_SPECS[verdict.replacement];
467
+
468
+ const replacementOnType =
469
+ replacementSpec === undefined
470
+ ? undefined
471
+ : verdictOf(replacementSpec, type, checker);
472
+
473
+ return (
474
+ onType?.kind === 'constant' &&
475
+ replacementOnType?.kind === 'constant' &&
476
+ replacementOnType.value === onType.value
477
+ );
478
+ };
479
+
406
480
  /**
407
481
  * Decomposes a type into its union members. Returns `undefined` when any member
408
482
  * is opaque (`any` / `unknown` / `never`) or deferred (type parameter, indexed
@@ -413,7 +487,9 @@ const collectUnionParts = (type: ts.Type): readonly ts.Type[] | undefined => {
413
487
  const parts = type.isUnion() ? type.types : ([type] as const);
414
488
 
415
489
  for (const part of parts) {
416
- if ((part.flags & DEFERRED_OR_OPAQUE_FLAGS) !== 0) return undefined;
490
+ if ((part.flags & DEFERRED_OR_OPAQUE_FLAGS) !== 0) {
491
+ return undefined;
492
+ }
417
493
  }
418
494
 
419
495
  return parts;
@@ -473,19 +549,33 @@ const isCallableType = (
473
549
  const classifyAtom = (type: ts.Type): Atom => {
474
550
  const { flags } = type;
475
551
 
476
- if ((flags & ts.TypeFlags.Undefined) !== 0) return 'undefined';
552
+ if ((flags & ts.TypeFlags.Undefined) !== 0) {
553
+ return 'undefined';
554
+ }
477
555
 
478
- if ((flags & ts.TypeFlags.Null) !== 0) return 'null';
556
+ if ((flags & ts.TypeFlags.Null) !== 0) {
557
+ return 'null';
558
+ }
479
559
 
480
- if ((flags & ts.TypeFlags.BooleanLike) !== 0) return 'boolean';
560
+ if ((flags & ts.TypeFlags.BooleanLike) !== 0) {
561
+ return 'boolean';
562
+ }
481
563
 
482
- if ((flags & ts.TypeFlags.NumberLike) !== 0) return 'number';
564
+ if ((flags & ts.TypeFlags.NumberLike) !== 0) {
565
+ return 'number';
566
+ }
483
567
 
484
- if ((flags & ts.TypeFlags.StringLike) !== 0) return 'string';
568
+ if ((flags & ts.TypeFlags.StringLike) !== 0) {
569
+ return 'string';
570
+ }
485
571
 
486
- if ((flags & ts.TypeFlags.BigIntLike) !== 0) return 'bigint';
572
+ if ((flags & ts.TypeFlags.BigIntLike) !== 0) {
573
+ return 'bigint';
574
+ }
487
575
 
488
- if ((flags & ts.TypeFlags.ESSymbolLike) !== 0) return 'symbol';
576
+ if ((flags & ts.TypeFlags.ESSymbolLike) !== 0) {
577
+ return 'symbol';
578
+ }
489
579
 
490
580
  // Branded primitives (e.g. `NonEmptyString = string & {...}`) are
491
581
  // intersections; classify them by their underlying primitive constituent.
@@ -493,7 +583,9 @@ const classifyAtom = (type: ts.Type): Atom => {
493
583
  for (const constituent of type.types) {
494
584
  const atom = classifyAtom(constituent);
495
585
 
496
- if (atom !== 'other') return atom;
586
+ if (atom !== 'other') {
587
+ return atom;
588
+ }
497
589
  }
498
590
  }
499
591
 
@@ -525,13 +617,19 @@ const isGuaranteedNonEmptyString = (
525
617
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
526
618
  checker: ts.TypeChecker,
527
619
  ): boolean => {
528
- if (type.isStringLiteral()) return type.value !== '';
620
+ if (type.isStringLiteral()) {
621
+ return type.value !== '';
622
+ }
529
623
 
530
- if (type.getProperty(BRAND_MARKER_PROPERTY) === undefined) return false;
624
+ if (type.getProperty(BRAND_MARKER_PROPERTY) === undefined) {
625
+ return false;
626
+ }
531
627
 
532
628
  const minLengthSymbol = type.getProperty(MIN_LENGTH_BRAND_KEY);
533
629
 
534
- if (minLengthSymbol === undefined) return false;
630
+ if (minLengthSymbol === undefined) {
631
+ return false;
632
+ }
535
633
 
536
634
  // The `MinLength` brand value is `MinLengthTuple<N, 0>`; it has a required
537
635
  // first element (`'0'`) exactly when `N >= 1`, i.e. the string is non-empty.
@@ -547,11 +645,8 @@ const isGuaranteedNonEmptyString = (
547
645
  * literal and non-string types cannot.
548
646
  */
549
647
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
550
- const couldBeNonEmptyString = (type: ts.Type): boolean => {
551
- if (type.isStringLiteral()) return type.value !== '';
552
-
553
- return classifyAtom(type) === 'string';
554
- };
648
+ const couldBeNonEmptyString = (type: ts.Type): boolean =>
649
+ type.isStringLiteral() ? type.value !== '' : classifyAtom(type) === 'string';
555
650
 
556
651
  /**
557
652
  * Returns `true` when evaluating the expression cannot have observable side
@@ -574,6 +669,12 @@ const isSideEffectFreeArg = (node: DeepReadonly<TSESTree.Node>): boolean => {
574
669
  (!node.computed || isSideEffectFreeArg(node.property))
575
670
  );
576
671
 
672
+ case AST_NODE_TYPES.TSAsExpression:
673
+ case AST_NODE_TYPES.TSNonNullExpression:
674
+ case AST_NODE_TYPES.TSSatisfiesExpression:
675
+ case AST_NODE_TYPES.TSTypeAssertion:
676
+ return isSideEffectFreeArg(node.expression);
677
+
577
678
  default:
578
679
  return false;
579
680
  }
@@ -460,3 +460,103 @@ describe('no-unnecessary-type-guard', () => {
460
460
  ],
461
461
  });
462
462
  });
463
+
464
+ describe('no-unnecessary-type-guard through type wrappers', () => {
465
+ tester.run('no-unnecessary-type-guard', noUnnecessaryTypeGuard, {
466
+ valid: [
467
+ {
468
+ name: 'isNullish on a value cast away from null',
469
+ code: dedent`
470
+ import { isNullish } from 'ts-data-forge';
471
+ declare const x: string | null | undefined;
472
+ const y = isNullish(x as string | undefined);
473
+ `,
474
+ },
475
+ {
476
+ name: 'isNonNullish on a value cast away from null',
477
+ code: dedent`
478
+ import { isNonNullish } from 'ts-data-forge';
479
+ declare const x: string | null | undefined;
480
+ const y = isNonNullish(x as string | undefined);
481
+ `,
482
+ },
483
+ {
484
+ name: 'isNonEmptyString on a string cast to NonEmptyString',
485
+ code: dedent`
486
+ import { type NonEmptyString } from 'ts-type-forge';
487
+ import { isNonEmptyString } from 'ts-data-forge';
488
+ declare const x: string | null | undefined;
489
+ const y = isNonEmptyString(x as NonEmptyString | null | undefined);
490
+ `,
491
+ },
492
+ {
493
+ name: 'isNull on a value whose `!` does not remove the null',
494
+ code: dedent`
495
+ import { isNull } from 'ts-data-forge';
496
+ declare const x: string | null;
497
+ const y = isNull(x!);
498
+ `,
499
+ },
500
+ {
501
+ name: 'isNull on a value widened to include null: the `as` is the author saying it may be null',
502
+ code: dedent`
503
+ import { isNull } from 'ts-data-forge';
504
+ declare const x: string;
505
+ const y = isNull(x as string | null);
506
+ `,
507
+ },
508
+ {
509
+ name: 'isNullish on a value widened to include null is not narrowed to isUndefined',
510
+ code: dedent`
511
+ import { isNullish } from 'ts-data-forge';
512
+ declare const x: string | undefined;
513
+ const y = isNullish(x as string | null | undefined);
514
+ `,
515
+ },
516
+ ],
517
+ invalid: [
518
+ {
519
+ name: 'isNonNullish on a value whose `!` does not remove the null',
520
+ code: dedent`
521
+ import { isNonNullish, isNotNull } from 'ts-data-forge';
522
+ declare const x: string | null;
523
+ const y = isNonNullish(x!);
524
+ `,
525
+ output: dedent`
526
+ import { isNonNullish, isNotNull } from 'ts-data-forge';
527
+ declare const x: string | null;
528
+ const y = isNotNull(x!);
529
+ `,
530
+ errors: [{ messageId: 'replaceTypeGuard' }],
531
+ },
532
+ {
533
+ name: 'isNull on a value widened to exclude null still, at every layer',
534
+ code: dedent`
535
+ import { isNull } from 'ts-data-forge';
536
+ declare const x: string;
537
+ const y = isNull(x as string | undefined);
538
+ `,
539
+ output: dedent`
540
+ import { isNull } from 'ts-data-forge';
541
+ declare const x: string;
542
+ const y = false;
543
+ `,
544
+ errors: [{ messageId: 'alwaysFalse' }],
545
+ },
546
+ {
547
+ name: 'isNullish replaced on the value under `satisfies`, keeping the wrapper',
548
+ code: dedent`
549
+ import { isNullish, isUndefined } from 'ts-data-forge';
550
+ declare const x: string | undefined;
551
+ const y = isNullish(x satisfies string | null | undefined);
552
+ `,
553
+ output: dedent`
554
+ import { isNullish, isUndefined } from 'ts-data-forge';
555
+ declare const x: string | undefined;
556
+ const y = isUndefined(x satisfies string | null | undefined);
557
+ `,
558
+ errors: [{ messageId: 'replaceTypeGuard' }],
559
+ },
560
+ ],
561
+ });
562
+ });
@@ -3,7 +3,7 @@ import {
3
3
  type TSESLint,
4
4
  type TSESTree,
5
5
  } from '@typescript-eslint/utils';
6
- import { type DeepReadonly } from 'ts-type-forge';
6
+ import { skipTypeWrappers } from './ast-utils.mjs';
7
7
  import {
8
8
  buildImportFixes,
9
9
  getNamedImports,
@@ -40,7 +40,9 @@ export const preferArrIsArray: TSESLint.RuleModule<MessageIds, Options> = {
40
40
 
41
41
  return {
42
42
  CallExpression: (node) => {
43
- if (!isArrayIsArrayCall(node)) return;
43
+ if (!isArrayIsArrayCall(node)) {
44
+ return;
45
+ }
44
46
 
45
47
  mut_nodesToFix.push(node);
46
48
  },
@@ -59,17 +61,19 @@ export const preferArrIsArray: TSESLint.RuleModule<MessageIds, Options> = {
59
61
  fix: (fixer) => {
60
62
  const callee = node.callee;
61
63
 
62
- if (callee.type !== AST_NODE_TYPES.MemberExpression) return [];
63
-
64
- const replacement = `Arr.isArray${sourceCode.getText(node).slice(sourceCode.getText(callee).length)}`;
64
+ if (callee.type !== AST_NODE_TYPES.MemberExpression) {
65
+ return [];
66
+ }
65
67
 
68
+ // Only the callee is replaced: parentheses the source put around
69
+ // it are outside its range, and stay balanced.
66
70
  // Add import only for the first node and only if not already imported
67
71
  const importFixes =
68
72
  index === 0 && !hasArrImport
69
73
  ? buildImportFixes(fixer, program, tsDataForgeImport, ['Arr'])
70
74
  : [];
71
75
 
72
- return [...importFixes, fixer.replaceText(node, replacement)];
76
+ return [...importFixes, fixer.replaceText(callee, 'Arr.isArray')];
73
77
  },
74
78
  });
75
79
  }
@@ -80,19 +84,34 @@ export const preferArrIsArray: TSESLint.RuleModule<MessageIds, Options> = {
80
84
  } as const;
81
85
 
82
86
  const isArrayIsArrayCall = (
83
- node: DeepReadonly<TSESTree.CallExpression>,
87
+ // AST nodes hold mutable child arrays, so they are not deeply readonly.
88
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
89
+ node: TSESTree.CallExpression,
84
90
  ): boolean => {
85
- if (node.callee.type !== AST_NODE_TYPES.MemberExpression) return false;
91
+ if (node.callee.type !== AST_NODE_TYPES.MemberExpression) {
92
+ return false;
93
+ }
94
+
95
+ const { property } = node.callee;
86
96
 
87
- const { object, property } = node.callee;
97
+ // `(Array as ArrayConstructor).isArray` is still `Array.isArray`.
98
+ const object = skipTypeWrappers(node.callee.object);
88
99
 
89
- if (object.type !== AST_NODE_TYPES.Identifier) return false;
100
+ if (object.type !== AST_NODE_TYPES.Identifier) {
101
+ return false;
102
+ }
90
103
 
91
- if (object.name !== 'Array') return false;
104
+ if (object.name !== 'Array') {
105
+ return false;
106
+ }
92
107
 
93
- if (property.type !== AST_NODE_TYPES.Identifier) return false;
108
+ if (property.type !== AST_NODE_TYPES.Identifier) {
109
+ return false;
110
+ }
94
111
 
95
- if (property.name !== 'isArray') return false;
112
+ if (property.name !== 'isArray') {
113
+ return false;
114
+ }
96
115
 
97
116
  return true;
98
117
  };