Yeah, so this crashes in node. On the other hand, if we go back to the way it was where typescript is complaining, but we run this, this works just fine. So there seems to be a disagreement here between typescript and node, and we can't satisfy both at the same time. Seeing this, you might reasonably wonder, is this a typescript bug, or are the types wrong?
In order to determine that for ourselves, we need to learn a few pieces of background first. The first one is how typescript and node even know what file we're talking about when we say import helmet or require helmet. It's going to be easier to explain this one, this exports field and package JSON when we're back in the code, so let's leave it behind for a moment. The second is module format detection. I think I mentioned that node supports both ESM and common JS, so it needs a way to determine which file is which format, and it does that purely by file extension. A .mjs file is always going to be ESM, a .cjs file is always going to be common JS, and a .js file, we have to look up at the nearest package JSON and if it has this special type module field, it's going to be ESM, otherwise common JS. All right.
The third thing is the relationships between the different kinds of files that JavaScript deals in. When you run TSC on an input.ts file, you get one output.js file and one output.d.ts file, which is called a declaration file. When you publish code for other people to consume, you usually want to ship them the raw, compiled JavaScript. That way their run time doesn't need another compile step before they run it. But then if that user is also using TypeScript themselves, or just an editor with smart TypeScript-powered language features, you want to give them this declaration file, which contains everything that it needs to know about the JavaScript file, including the types that have been erased from the original TypeScript file. Because these two outputs are produced together at the same time, when the user's compiler or editor sees a declaration file, um, it actually doesn't even need to go and look and make sure that the JS file exists. Because of this relationship, it can simply assume it exists, know everything it needs to know about the structure and the types, and also the file extension. When it sees .d.ts, it knows that there must be a .js file there. And the same thing holds for the format-specific file extensions that we were discussing earlier. A .mts file produces a .mjs file, and we get a .d.mts file alongside that. And then we have an analog for the CommonJS versions here. So it might seem like I'm forcing a relatively simple point here, but you'd be surprised how many problems trace the root cause to breaking this relationship somehow.
The next thing we need to learn is, what happens if we were to try to compile this export default down to CommonJS? Default exports in ESM are just a special form of named import with special syntax attached. So all we're going to do is just attach a named property assignment here on module.exports with the value that we're exporting. And then we'll also define this flag here that just says, hey, I've been transpiled down from ESM to CommonJS. Our declaration file, on the other hand, kind of retains this esm syntax. Okay, almost done here. The final thing that we need to look at is what happens when we try to import that same transpiled export default that we just looked at. So here we have our export default transpiled down to CommonJS again. And if we're importing this in another file that is also being compiled down to CommonJS, then it makes sense that what we should get with a default import there is going to be the value that we default exported.
Comments