Unlocking the Power of TypeScript in JavaScript Projects: A Practical Guide

Unlocking the Power of TypeScript in JavaScript Projects A Practical Guide

Introduction

TypeScript in JavaScript Projects: TypeScript offers important advantages like static types, safer refactoring, and richer IDE tooling for building robust JavaScript applications.

However, introducing TypeScript just for incremental benefits can feel too heavy, especially in mature JavaScript codebases.

This guide covers practical techniques to bring TypeScript’s capabilities to JavaScript through gradual adoption. We’ll look at:

  • Gradual typing of core modules and functions
  • Type checking JavaScript via JSDoc annotations
  • Using Declaration Merging to add types
  • Generating types from runtime values
  • Converting files one by one to TypeScript

By the end, you’ll understand incremental approaches for migrating JavaScript to TypeScript at your own pace.

Benefits of TypeScript in JavaScript

Let’s first recap some of the key benefits that TypeScript provides:

Types for Safety and Documentation

Type annotations improve code understandability. And during development, the TypeScript compiler uses types to catch many bugs and unintended behaviors.

Cleaner Refactoring

The typechecker validates code behavior is maintained while renaming/moving code. This enables large scale refactors with confidence.

Editor Tooling

Strong types enable editor features like autocomplete, inline documentation and intelligent code navigation in IDEs like VSCode.

Future-proofing Code

TypeScript makes adopting future JavaScript features like decorators easier via gradual typed adoption.

Supporting Paradigms like OOP

TypeScript adds missing object-oriented capabilities like classes and interfaces to JavaScript.

Catching Errors Early

By compiling to plain JavaScript, most errors surface during development rather than at runtime in users’ browsers.

For large, critical JavaScript applications, leveraging TypeScript can significantly improve code quality, reliability, and developer productivity.

But mandating a full TypeScript rewrite often faces resistance. Next we’ll explore techniques to incrementally introduce types and reap benefits.

Gradual Typing of Core Modules

The first step is identifying high value JavaScript modules and adding gradual types.

For example, consider a core Person module:

person.js

function createPerson(name) {
  return { 
    name: name,

    setName(newName) {
      this.name = newName;
    },

    greet() {
      console.log(`Hello ${this.name}!`)
    }
  }
} 

function getName(person) {
  return person.name; 
}

module.exports = {
  createPerson,
  getName  
};

We can add types using JSDoc comments like:

person.js

/**
 * @typedef {Object} Person
 * @property {string} name
*/

/**
 * @param {string} name
 * @returns {Person}
*/
function createPerson(name) {
  // ... 
}

/**
 * @param {Person} person 
 * @returns {string}
*/
function getName(person) {
 // ...
}

This documents the Person type and function signatures.

We also need a tsconfig.json to enable parsing JSDoc types:

{
  "compilerOptions": {
    "checkJs": true
  }
}

Now in consuming code we get autocomplete and error checking:

main.js

const personModule = require('./person');

let person = personModule.createPerson(123); // Error, expects string

person.dance(); // Error, unknown method

This validates proper usage of the person module through types. As more modules are typed, benefits compound.

Typed Definition Files

For untyped third party modules, we can create .d.ts definition files containing JSDoc types.

For example, for the lodash utility library:

lodash.d.ts

/**
 * @param {Array} array The array to shuffle.
 * @returns {Array} The shuffled array.
 */
declare function shuffle(array: any[]): any[];

Consumers now get types for lodash:

const _ = require('lodash');

_.shuffle([1, 2, 3]); // Error if wrong args

Definitions can be placed in a central typings folder and referenced in tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "*": ["typings/*"] 
    }
  } 
}

This allows gradually typing untyped modules used in a codebase.

Declaration Merging

We can also use Declaration Merging to add types to existing modules directly:

person.d.ts

declare module 'person' {
  export interface Person {
    name: string;
  }

  export function createPerson(name: string): Person;
  export function getName(person: Person): string; 
}

This augments the types of person without needing to touch the original JavaScript source!

Declaration Merging allows non-intrusively adding types even to third party modules.

Typing Constants and Runtime Values

TypeScript also has utilities for typing runtime JavaScript values:

// Load config from runtime
const config = require('./config'); 

// Use typeof to extract type
/** @type {typeof config} */
const typedConfig = config;

typeof extracts the runtime type for typing.

We can also type literal values:

/** @type {{url: string, timeout: number}} */
const constants = {
  url: 'https://api.com',
  timeout: 1000
};

constants.url = 123; // Error

This casts the inline object literal to a typed structure.

Incremental Migration of Files

Once core files and interfaces are typed, individual .js files can be migrated to .ts one by one:

  1. Rename file.js to file.ts.
  2. Add parameter and return types.
  3. Fix any errors reported by compiler.
  4. Convert ES5 code like classes to ES6.

For example:

person.js

function createPerson() {
  // ...
}

Becomes:

person.ts

function createPerson(name: string): Person {
  // ... 
}

class Person {
  // ...
}

Migrating files incrementally in this style allows typing the most impactful parts of a codebase first.

Gradual Typing With JSDoc and TSConfig

JSDoc combined with tsconfig.json options provides a toolkit for gradually adding types within JavaScript code:

/** @typedef {import('./types').Person} */

/** @type {Person} */
let person;

person = { 
  dance() {} // Error  
};

No .ts files needed! This style is great for typing existing JS during transition to TS.

Conclusion

TypeScript does not need to be an all-or-nothing overhaul. There are many techniques to incrementally introduce typing:

  • Annotate existing JavaScript with JSDoc types
  • Add definition files for untyped modules
  • Merge new types into original modules via declaration merging
  • Type runtime config and constants
  • Gradually convert .js files to .ts

Starting with critical interfaces and utilities, TypeScript can provide incremental benefits through light annotation.

This allows smoothing the migration curve from JavaScript to TypeScript based on an app’s needs.

Treat typing as a dial rather than a switch – turn it up notch-by-notch to improve JavaScript code and productivity over time.

Frequently Asked Questions

What percentage of code needs to be typed to get benefits?

Even 20-30% typing in key areas like interfaces and function APIs unlocks significant gains.

Should .js and .ts files co-exist long-term or should all code convert eventually?

Long-term, consolidating to .ts maximizes benefits. But .js and .ts can co-exist indefinitely if needed.

Does adopting TypeScript slow down development velocity?

Initially yes due to learning curve. But types long-term improve productivity through bug prevention and code navigation.

What JavaScript features might break when moving to TypeScript?

Patterns like monkey patching objects, invalid this usage, and loose any typing need revision. But compiler helps pinpoint issues.

Is it better to rewrite JavaScript to TypeScript or gradually adopt types?

For large codebases, gradual adoption is preferred. Smaller code can feasibly be rewritten applying learnings from original JavaScript.

Leave a Reply

Your email address will not be published. Required fields are marked *