Skip to content

Migrate full-fledged TypeScript to TypeScript with erable type annotations only. Compatible with the type annotation proposal as well as NodeJS's strip-types

Notifications You must be signed in to change notification settings

nicojs/type-annotationify

Repository files navigation

Mutation testing badge

Type Annotationify

This is a simple tool to migrate full-fledged TypeScript code to type-annotated TypeScript code that is compatible with the type annotation proposal as well as NodeJS's--experimental-strip-types mode.

Live demo: nicojs.github.io/type-annotationify/

Example of class parameter properties transformation

Note

See running typescript natively on the NodeJS docs page for more info on --experimental-strip-types.

Status

Syntax Status Notes
Parameter Properties
Parameter Properties with super() call
Plain Enum
Number Enum
String Enum
Const Enum
Type assertion expressions I.e. <string>value --> value as string
Namespaces With some limitations
Rewrite relative import extensions with --relative-import-extensions

Installation

npm install -g type-annotationify@latest
# OR simply run directly with
npx type-annotationify@latest

Usage

type-annotationify [options] <pattern-to-typescript-files>

The default pattern is **/!(*.d).?(m|c)ts?(x), excluding 'node_modules'. In other words, by default all TypeScript files are matched (also in subdirectories) except declaration files (d.ts).

This will convert all the TypeScript files that match the pattern to type-annotated TypeScript files in place. So be sure to commit your code before running this tool.

Tip

Running type-annotationify will rewrite your TypeScript files without taking your formatting into account. It is recommended to run prettier or another formatter after running type-annotationify. If you use manual formatting, it might be faster to do the work yourself

Options

--dry

Don't write the changes to disk, but print changes that would have been made to the console.

--explicit-property-types

Add type annotations to properties. See Parameter Properties for more info.

--help

Print the help message and exit.

--no-enum-namespace-declaration

Disable the declare namespace output for enums. For example:

// ❌ Disable this output for enums
declare namespace Message {
  type Start = typeof Message.Start;
  type Stop = typeof Message.Stop;
}

This makes it so you can't use enum values (i.e. Message.Start) as a type, but means a far cleaner output in general. This might result in compile errors, which are pretty easy to fix yourself:

- let message: Message.Start;
+ let message: typeof Message.Start;

--relative-import-extensions

Rewrite relative file extensions in import specifiers to .ts, .cts or .mts. See Relative import extensions for more info.

Transformations

Parameter Properties

Input:

class Foo {
  constructor(
    public bar: string,
    readonly baz: boolean,
    protected qux = 42,
  ) {}
}

Type-annotationifies as:

Default --explicit-property-types
class Foo {
  public bar;
  readonly baz;
  protected qux;
  constructor(bar: string, baz: boolean, qux = 42) {
    this.bar = bar;
    this.baz = baz;
    this.qux = qux;
  }
}
class Foo {
  public bar: string;
  readonly baz: boolean;
  protected qux;
  constructor(bar: string, baz: boolean, qux = 42) {
    this.bar = bar;
    this.baz = baz;
    this.qux = qux;
  }
}

When a super() call is present, the assignments in the constructor are moved to below the super() call (like in normal TypeScript transpilation).

The property type annotations are left out by default, as the TypeScript compiler infers them from the constructor assignments. This is better for code maintainability (every type is listed once instead of twice), but does come with some limitations. However, if you want to be explicit, you can enable the --explicit-property-types option.

Parameter property transformation limitations

  1. It assumes noImplicitAny is enabled. Without it, the inference from the assignments in the constructor doesn't work. You can opt-out of this by enabling the --explicit-property-types option.
  2. When you use the property as an assertion function you will get an error. For example:
    interface Options {
      Foo: string;
    }
    type OptionsValidator = (o: unknown) => asserts o is Options;
    class ConfigReader {
      private readonly validator;
      constructor(validator: OptionsValidator) {
        this.validator = validator;
      }
      public doValidate(options: unknown): Options {
        this.validator(options);
        //   ^^^^^^^^^ 💥 Assertions require every name in the call target to be declared with an explicit type annotation.(2775)
        return options;
      }
    }
    The solution is to add the type annotation to the property manually.
    - private readonly validator;
    + private readonly validator: OptionsValidator;
    Or enable the --explicit-property-types option.

Enum transformations

An enum transforms to 3 components. The goal is to get as close to a drop-in replacement as possible, without transforming the consuming side of enums.

Input:

enum Message {
  Start,
  Stop,
}

Note

String enums are also supported.

Type-annotationifies as:

const Message = {
  0: 'Start',
  1: 'Stop',
  Start: 0,
  Stop: 1,
} as const;
type Message = (typeof Message)[keyof typeof Message & string];
declare namespace Message {
  type Start = typeof Message.Start;
  type Stop = typeof Message.Stop;
}

That's a mouthful. Let's break down each part.

  • The object literal
    const Message = {
      0: 'Start',
      1: 'Stop',
      Start: 0,
      Stop: 1,
    } as const;
    This allows you to use Message as a value: let message = Message.Start. This is the actual JS footprint of the enum. The as const assertion, but makes sure we can use typeof Message.Start.
  • type Message = (typeof Message)[keyof typeof Message & string];
    This allows you to use Message as a type: let message: Message. Let's break it down further:
    • typeof Message means the object shape {0: 'Start', 1: 'Stop', Start: 0, Stop: 1 }
    • keyof typeof Message means the keys of that object: 0 | 1 | 'Start' | 'Stop'
    • & string filters out the keys that are also strings: 'Start' | 'Stop'
    • (typeof Message)[keyof typeof Message & string] means the type of the values of the object with the keys 'Start' | 'Stop', so only values 0 | 1. This was the backing value of the original enum.
  • The declare namespace
    declare namespace Message {
      type Start = typeof Message.Start;
      type Stop = typeof Message.Stop;
    }
    This allows you to use Message.Start as a type: let message: Message.Start. This can be disabled with the --no-enum-namespace-declaration option.

Enum transformation limitations

  1. Type inference of enum values are more narrow after the transformation.
    const bottle = {
      message: Message.Start,
    };
    bottle.message = Message.Stop;
    //     ^^^^^^^ 💥 Type '1' is not assignable to type '0'.(2322)
    Playground link
    In this example, the type of bottle.message is inferred as 0 instead of Message. This can be solved with a type annotation.
    - const bottle = {
    + const bottle: { message: Message } = {
  2. A const enum is transformed to a regular enum. This is because the caller-side of a const enum will assume that there is an actual value after type-stripping.

Type assertion expressions

Input:

const value = <string>JSON.parse('"test"');

Type-annotationifies as:

const value = JSON.parse('"test"') as string;

Namespaces

Namespace transformation is a bit more complex. The goal is to keep the namespace as close to the original as possible, while still using erasable types only. It unfortunately needs a couple of @ts-ignore comments to make it work. For more info and reasoning, see #26.

Input:

namespace Geometry {
  console.log('Foo is defined');
  export const pi = 3.141527;
  export function areaOfCircle(radius: number) {
    return pi * radius ** 2;
  }
}

Type-annotationifies as:

// @ts-ignore Migrated namespace with type-annotationify
declare namespace Geometry {
  const pi = 3.141527;
  function areaOfCircle(radius: number): number;
}
// @ts-ignore Migrated namespace with type-annotationify
var Geometry: Geometry;
{
  // @ts-ignore Migrated namespace with type-annotationify
  Geometry ??= {};
  console.log('Foo is defined');
  // @ts-ignore Migrated namespace with type-annotationify
  Geometry.pi = 3.141527;
  function areaOfCircle(radius: number) {
    return Geometry.pi * radius ** 2;
  }
  Geometry.areaOfCircle = areaOfCircle;
}

Namespace transformation limitations

  1. Nested namespaces are not supported yet. Please open an issue if you want support for this.
  2. Referring to identifiers with their local name across namespaces declarations with the same name is not supported. For example:
    namespace Geometry {
      export const pi = 3.141527;
    }
    namespace Geometry {
      export function areaOfCircle(radius: number) {
        return pi * radius ** 2;
      }
    }
    This will result in an error because pi is not defined in the second namespace. The solution is to refer to pi as Geometry.pi:
    - return pi * radius ** 2;
    + return Geometry.pi * radius ** 2;
  3. The @ts-ignore comments are necessary to make the namespace work. This is because there are a bunch of illegal TypeScript constructs needed, like declaring a namespace and a variable with the same name. This also means that TypeScript is turned off entirely for these statements.

Relative import extensions

You can let type-annotationify rewrite relative import extensions from .js, .cjs or .mjs to .ts, .cts or .mts respectively. Since this isn't strictly 'type-annotationification', you'll need to enable this using the --relative-import-extensions flag.

Input

import { foo } from './foo.js';

Type-annotationifies as:

import { foo } from './foo.ts';

This is useful when you want to use the --experimental-strip-types flag in NodeJS to run your TS code directly, where in the past you needed to transpile it first.

Tip

After you've rewritten your imports, you should not forget to enable allowImportingTsExtensions in your tsconfig. If you still want to transpile your code to .js with tsc, you will also should enable rewriteRelativeImportExtensions in your tsconfig.

FAQ

Why would I want to use this tool?

  1. You want to be aligned with the upcoming type annotation proposal.
  2. You want to use NodeJS's --experimental-strip-types mode.
  3. You want to use TypeScript --erasableSyntaxOnly option.

How does this tool work?

This tool uses the TypeScript compiler API to parse the TypeScript code and then rewrite it with type annotations.

Why do I get ExperimentalWarning errors?

This tool uses plain NodeJS as much as possible. It doesn't rely on glob or other libraries to reduce the download size and maintenance (the only dependency is TypeScript itself). That's also why the minimal version of node is set to 22.

About

Migrate full-fledged TypeScript to TypeScript with erable type annotations only. Compatible with the type annotation proposal as well as NodeJS's strip-types

Resources

Stars

Watchers

Forks

Packages

No packages published