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/
Note
See running typescript natively on the NodeJS docs page for more info on --experimental-strip-types
.
👷♂️ Work in progress. This tool is still in development, and not all syntax transformations are supported yet.
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 | ❌ | This might turn out to be impossible to do, to be investigated, see #26 |
Rewrite file extensions in import specifier | ❌ | This might be included with an option in the future |
npm install -g type-annotationify@latest
# OR simply run directly with
npx type-annotationify@latest
type-annotationify <pattern-to-typescript-files>
The default pattern is **/!(*.d).?(m|c)ts?(x)
, excluding 'node_modules'.
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
Input:
class Foo {
constructor(
public bar: string,
readonly baz: boolean,
protected qux = 42,
) {}
}
Type-annotationifies as:
class Foo {
public bar;
readonly baz;
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, 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.
- It assumes
noImplicitAny
is enabled. Without it, the inference from the assignments in the constructor doesn't work. - When you use the property as an assertion function you will get an error. For example:
The solution is to add the type annotation to the property manually.
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; } }
- private readonly validator; + private readonly validator: OptionsValidator;
An enum transforms to 4 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:
type Message = 0 | 1;
type MessageKeys = 'Start' | 'Stop';
const Message = {
0: 'Start',
1: 'Stop',
Start: 0,
Stop: 1,
} satisfies Record<Message, MessageKeys> & Record<MessageKeys, Message>;
declare namespace Message {
type Start = typeof Message.Start;
type Stop = typeof Message.Stop;
}
That's a mouthful. Let's break down each part.
type Message = 0 | 1
This allows you to useMessage
as a type:let message: Message
. The backing value of the enum was a number (0
or1
), so thats what is used here.type MessageKeys = 'Start' | 'Stop'
This is a convenience type alias used in the object literal later.- The object literal
This allows you to use
const Message = { 0: 'Start', 1: 'Stop', Start: 0, Stop: 1, } satisfies Record<Message, MessageKeys> & Record<MessageKeys, Message>;
Message
as a value:let message = Message.Start
. This is the actual JS footprint of the enum. Thesatisfies
operator isn't strictly necessary, but makes sure theMessage
type andMessage
value are kept in sync if you decide to change theMessage
"enum" later. - The namespace
This allows you to use
declare namespace Message { type Start = typeof Message.Start; type Stop = typeof Message.Stop; }
Message.Start
as a type:let message: Message.Start
.
- Type inference of enum values are more narrow after the transformation.
Playground link
const bottle = { message: Message.Start, }; bottle.message = Message.Stop; // ^^^^^^^ 💥 Type '1' is not assignable to type '0'.(2322)
In this example, the type ofbottle.message
is inferred as0
instead ofMessage
. This can be solved with a type annotation.- const bottle = { + const bottle: { message: Message } = {
- 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.
- You want to be alined with the upcoming type annotation proposal.
- You want to use NodeJS's --experimental-strip-types mode.
This tool uses the TypeScript compiler API to parse the TypeScript code and then rewrite it with type annotations.
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.