Skip to main content

Developer Guide

Type-Aware AST Transforms: How ts-morph Keeps indexof_to_includes Honest

Om SherikarMay 24, 2026

There is a one-line version of the rewrite arr.indexOf(x) !== -1 to arr.includes(x). It is a regex. It is also wrong.

// What the naive rewrite does
if (s.indexOf("/") > 5) doThing();
// becomes
if (s.includes("/")) doThing();

The first version checks whether the / character appears past position 5 in the string. The second checks whether the string contains a / at all. Those are not the same condition. The naive rewrite breaks the code, silently, with no error at compile time and a different runtime behavior that may take weeks to surface as a bug report.

This post walks through how Refactron's indexof_to_includes transform avoids that class of failure, and how the same pattern generalizes to the other two type-aware transforms we shipped in v0.2.3.

The receiver type matters

The first thing the type-aware version checks is the type of the LHS of the indexOf call. We accept only string, Array of T, and ReadonlyArray of T. Anything else is refused with a precondition failure, which means the transform emits no FileChange and the engine moves on.

The check is union-aware. A parameter typed string or Buffer is refused, because Buffer's indexOf returns a different result type and Buffer's includes has different semantics around byte versus character matching. A parameter typed string or Array of string is accepted, because both arms of the union have an includes method with the same membership semantics.

Why getType rather than checking the type annotation in the source: type annotations are often missing from real code, and even when they are present they are not the full type. A function like function f(arr) { return arr.indexOf(x) !== -1 } has no annotation on arr, but the type the compiler infers from call sites might still be string-array. We want the inferred type, not the annotated type. The compiler already does the inference. We just have to ask for it.

The comparison operator matters too

The transform maps four comparison patterns:

  • arr.indexOf(x) !== -1 becomes arr.includes(x)
  • arr.indexOf(x) === -1 becomes !arr.includes(x)
  • arr.indexOf(x) < 0 becomes !arr.includes(x)
  • arr.indexOf(x) >= 0 becomes arr.includes(x)

Anything else is refused. arr.indexOf(x) > 5 is a position check, not a membership check — includes has no equivalent. arr.indexOf(x) + 1 is arithmetic on the position — includes has no equivalent. arr.indexOf(x) === 0 is a "starts-with" check — includes has no equivalent.

The comparison pattern matters because indexOf returns a number, and code can do anything to a number. The transform is correct only for the four patterns where the number is being treated as a boolean ("found" or "not found"). Every other pattern is treating the number as a position, and the transform has nothing useful to say about position checks.

The ES target matters

Array.prototype.includes is ES2016. String.prototype.includes is ES2015. The transform reads the project's tsconfig.json, resolves the target field — walking the extends chain, including the TypeScript 5+ array-extends form where extends accepts a list of base configs — and refuses the transform if the resolved target is lower than ES2016.

The resolved target is cached per project root for the lifetime of one Refactron run. tsconfig.json does not change during a session, and resolving the extends chain is not free.

Two edge cases worth knowing. First, a missing target field defaults to ES3 in older TypeScript and ES5 in newer TypeScript. We use whatever the TypeScript compiler reports rather than picking a default ourselves. Second, a project with multiple tsconfig files — a tsconfig.build.json plus a tsconfig.test.json, say — gets the nearest tsconfig to the file being transformed. We do not assume tsconfig.json at the project root is the authoritative one.

Same pattern, two more transforms

object_assign_to_spread rewrites Object.assign({}, a, b) to the spread form { ...a, ...b }. The type check is "is the first argument an object literal" — not just any expression. Object.assign(target, source) mutates target. The spread rewrite produces a new object. Those semantics are not interchangeable when target is anything but a fresh object literal in the call expression itself.

The transform also refuses spread-element sources — Object.assign({}, ...sources) — because the spread literal form does not preserve a runtime-list spread at the same call boundary. You would need a wrapper, and we are not in the business of inserting wrappers.

string_concat_to_template_literal rewrites a chain of string concatenations with the plus operator into a template literal. The type check is "is every operand on the plus chain string-typed per getType". The any and unknown types are refused — they could be objects whose toString method differs from the coercion semantics of string concatenation. Non-primitive operands are refused for the same reason. The transform also escapes any literal backticks and any dollar-brace sequences in string operands when generating the template literal source, because both would break the rewrite if left unescaped.

The cost

A type-aware ts-morph transform is roughly 4x the lines of code of the naive AST-only version. It is also roughly 1.5x slower per file, because getType is not free — it triggers type checker resolution on demand and the type checker does real work to answer.

That is the cost of not breaking your code. It is also the reason we did not ship these three transforms in v0.2.0. We needed the type-aware infrastructure — the tsconfig resolver, the cached project target, the union-aware type predicates, the precondition refusal path — to be stable first. Shipping three transforms that depend on infrastructure you have not stabilized is how you end up shipping a v0.2.1 patch release with a postmortem in it.

When to write a type-aware transform

Any time the rewrite is semantically sensitive to the runtime type of an operand. var to const is shape-based — the rewrite is correct regardless of what value is in the var. indexOf to includes is type-based — the rewrite is correct only when the receiver is a string or an array. If you cannot tell from the rewrite shape alone whether the rewrite is safe, you need a type-aware transform.

The corollary: most refactors are shape-based, which is why most refactoring tools get away with AST-only logic. The ones that are not shape-based usually involve method calls on prototype chains where the same method name means different things on different receivers — String, Array, Buffer, Map, Set, Promise, Iterator. Anywhere a method name is overloaded across prototypes, your rewrite needs to know which prototype it is targeting. The compiler already knows. Ask it.

TypeScriptASTts-morphType InferenceDeveloper Guide
0 views0 clicks