DEV Community

loading...
Cover image for Calling JavaScript from TypeScript

Calling JavaScript from TypeScript

tkdodo profile image Dominik D Originally published at tkdodo.eu ใƒป4 min read

There is nothing better than starting a new project, on a green field. You can choose all the latest tech you want, and you can begin with great types right from the start.

Obviously, you then wake up from your dream and realise you have to maintain a project with 150k lines of legacy JavaScript code. If you are lucky, the team started to gradually migrate the codebase to TypeScript.

But it will take some time to "get there". Until then, you will need some interoperability between JavaScript and TypeScript.

Being in a JS file and calling a function defined in a .ts is trivial - it just worksโ„ข. But what about the other way around? Turns out - that's not so easy.

Example

Suppose you have a util function that you would like to import. It could be something as simple as:

export const sum = ({ first, second, third }) =>
    first + second + (third ?? 0)
Enter fullscreen mode Exit fullscreen mode

A stupid example, I know, but it'll do.

Setting up tsconfig.json

You're gonna have to set allowJs: true in your tsconfig if you want to be able to import that file. Otherwise, your import will error with:

TS7016: Could not find a declaration file for module './utils'.
'src/utils.js' implicitly has an 'any' type.
Enter fullscreen mode Exit fullscreen mode

Of course, I am assuming here that you have noImplicitAny turned on as well ๐Ÿ˜Š.

So with allowJs, TypeScript will start to accept .js files, and perform rudimentary type inference on them. The sum util will now be inferred as:

export const sum: function({ first: any, second: any, third: any }): any
Enter fullscreen mode Exit fullscreen mode

Which is good enough, not type-safe at all, but that wasn't part of the requirement. With that, we are all set-up. That wasn't hard, so where's the catch?

The catch

Maybe you've already noticed: The third parameter is actually optional. So we would like to call our function like that:

sum({ first: 1, second: 2 })
Enter fullscreen mode Exit fullscreen mode

Comparing this to the inferred type above, we will naturally get:

TS2345: Argument of type '{ first: number; second: number; }' is not assignable to parameter of type '{ first: any; second: any; third: any; }'.
    Property 'third' is missing in type '{ first: number; second: number; }' but required in type '{ first: any; second: any; third: any; }'.
Enter fullscreen mode Exit fullscreen mode

Solutions

There are multiple solutions to this problem, so you'll have to decide for yourself which one is best suited for your specific case:

use .d.ts files

You can turn off allowJs and write declaration files for all your JavaScript files. Depending on the amount of files, this might be feasible, or not. It can be as easy as this any stub:

export const sum: any
Enter fullscreen mode Exit fullscreen mode

This is substantially worse than the inferred version. You can of course be more specific than that, but you have to do it manually. And you have to remember to keep the both files in sync, so I'm not a big fan of this solution.

Don't destruct

The described problem is actually due to typescript performing better inference if you use destructuring. We could change the implementation to:

export const sum = (params) =>
    params.first + params.second + (params.third ?? 0)
Enter fullscreen mode Exit fullscreen mode

Now, TypeScript will just infer params as any, and we are again good to go. Especially if you are working with React components, destructing props is very common, so I'd also give this a pass.

Assign default parameters

export const sum = ({ first, second, third = 0 }) =>
    first + second + third
Enter fullscreen mode Exit fullscreen mode

I like this solution a lot, because the implementation is actually easier than before. The function's interface now shows what is optional, which is why TypeScript also knows that. This works well for variables where the default is clear, like booleans, where you can easily default to false.

If you don't know what a good default value would be, you could even cheat a bit and do this:

export const sum = ({ first, second, third = undefined }) =>
    first + second + (third ?? 0)
Enter fullscreen mode Exit fullscreen mode

๐Ÿคฏ

undefined will also be the default value even if you don't explicitly specify it, but now, TypeScript will let you. This is a non-invasive change, so if you have complex types where you can't easily come up with a default value, this seems like a good alternative.

Convert the file to TypeScript

type Params = {
    first: number
    second: number
    third?: number
}
export const sum = ({ first, second, third }: Params): number =>
    first + second + (third ?? 0)
Enter fullscreen mode Exit fullscreen mode

The long-term thing you probably want to do anyways - convert it to TypeScript. If it's feasible - go for this option.

Use JsDoc

This is the last option I have to offer, and I kinda like it because it represents the middle ground between things just being any and converting the whole file to TypeScript right away.

I never really understood why you would need this, but now I do. Adding JsDoc annotations to your JavaScript functions will:

  • Help TypeScript with type inference, thus making your call sides more safe.
  • Give you IntelliSense in your IDE.
  • Make it easier to finally migrate to TypeScript when the time is right.
/**
 * @param {{ first: number, second: number, third?: number }} params
 * @returns {number}
 */
export const sum = ({ first, second, third }) =>
    first + second + (third ?? 0)
Enter fullscreen mode Exit fullscreen mode

Of course, you can also just type them to any or omit the return type. You can be as specific as you want.

Bonus: TypeChecking js files

If you add the // @ts-check comment at the top of your js file, it will be type-checked almost like all your typescript files, and JsDoc annotations will be honoured ๐Ÿ˜ฎ. You can read more about the differences here.

What I ended up doing

I used JsDoc for the first time today when I had this exact problem.
I chose it over the other options because:

  • adding .d.ts files is tedious to maintain and will make my IDE stop navigating to the actual source ๐Ÿ˜’
  • I wanted to keep the destructuring ๐Ÿ˜•
  • Default parameters were hard to come up with as my case was much more complex ๐Ÿง
  • The file in question had 120+ lines of code ๐Ÿคจ
  • I wanted to make it easier for us to migrate when we fully convert that file ๐Ÿš€

What would you do? Let me know in the comments below โฌ‡๏ธ

Discussion

pic
Editor guide