In a bid to save you time, I’m going to give you the conclusion I came to after spending several hours both at work, and after work, researching this issue. Do not export enum ...
from .d.ts
files. Enums are runtime values and putting them inside .d.ts
files means they will very likely be ignored / erased when your TypeScript code is compiled to JavaScript.
However, this is just my conclusion. The following is how I got there.
This morning one of the developers on my team asked for a little help with a failing unit test. Here’s roughly the error:
FAIL src/index.test.ts
● Test suite failed to run
Cannot find module './@types/some-types' from 'src/index.ts'
Require stack:
src/index.ts
src/index.test.ts
However, Jest was able to find:
'@types/some-types.d.ts'
You might want to include a file extension in your import, or update your 'moduleFileExtensions', which is currently ['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'node'].
See https://jestjs.io/docs/configuration#modulefileextensions-arraystring
> 1 | import { testHello } from "./@types/some-types";
| ^
2 |
3 | export const myFunction = () => testHello;
4 |
at Resolver._throwModNotFoundError (node_modules/jest-resolve/build/resolver.js:427:11)
at Object.<anonymous> (src/index.ts:1:1)
at Object.<anonymous> (src/index.test.ts:1:1)
Test Suites: 1 failed, 1 total
Tests: 0 total
Snapshots: 0 total
Time: 0.946 s
Ran all test suites.
Code language: Shell Session (shell)
I’d been asked as I’d reviewed another of this person’s PR’s earlier in the week, and they had assumed I must know a thing or two about TypeScript as I’d flagged up an issue in their code. The thing is, I only spotted it because I’d suffered for about an hour on the same issue, so pointed it out to save them a headache. I’m not a TypeScript guru.
Anyway, intrigued, I asked to push their branch so I could have a play locally.
I’ve stripped down their change to the fundamentals just to illustrated:
// package.json
{
"name": "typescript-enum-exploration",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"test": "jest"
},
"author": "",
"license": "ISC",
"dependencies": {
},
"devDependencies": {
"@types/jest": "^29.5.5",
"@types/node": "^20.7.1",
"jest": "^29.7.0",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
}
}
Code language: TypeScript (typescript)
And the TypeScript config:
// tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"typeRoots": [
"./node_modules/@types",
"./src/@types"
],
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": [
"**/*.ts"
],
"exclude": [
"**/*.test.ts"
]
}
Code language: JSON / JSON with Comments (json)
The interesting part about that is the use of the typeRoots
, which we will get back to shortly.
Then the code:
// src/index.ts
import { Test } from "./@types/some-types";
console.log(Test.HELLO);
console.log(Test.GOODBYE);
// added just to show a unit test failure example
export const myFunction = () => Test.HELLO
Code language: TypeScript (typescript)
And the type definitions:
// src/@types/some-types.d.ts
export enum Test {
HELLO = "hello",
GOODBYE = "goodbye",
}
Code language: TypeScript (typescript)
In their code, they were doing something like this:
if (someString === Test.GOODBYE) { ...
Code language: TypeScript (typescript)
Which rather oddly was building fine, but failing in a Jest test run.
Here’s the Jest test to reproduce the issue:
// src/index.test.ts
import { myFunction } from "./index";
describe("index", () => {
it("should pass, right?", () => {
expect(myFunction()).toEqual("hello");
});
});
Code language: JavaScript (javascript)
My first thought is that this had something to do with the use of the .d.ts
file, as opposed to a regular plain old .ts
file. And that did turn out to be true.
But along the way I experienced some stuff.
Examining The Compiled Code
One thing that was really hard to do in the real project versus this massively slimmed down example was looking at the compiled / built JavaScript code.
Here’s what I get when I look at dist/index.js
:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.myFunction = void 0;
const some_types_1 = require("./@types/some-types");
console.log(some_types_1.Test.HELLO);
console.log(some_types_1.Test.GOODBYE);
// added just to show a unit test failure example
const myFunction = () => some_types_1.Test.HELLO;
exports.myFunction = myFunction;
Code language: JavaScript (javascript)
Now, I’d argue that this file is pretty hard to read when printed out in a web browser. But in the IDE, it’s fairly obvious what the immediate problem is:
Line 4, highlighted in yellow, indicates something is … missing.
And we can prove that by looking in the dist
directory:
I figured I’d try moving the enum
from the type root / some-types.d.ts
file, into a regular old .ts
file and retry:
// src/index.ts
export enum Test {
HELLO = "hello",
GOODBYE = "goodbye",
}
console.log(Test.HELLO);
console.log(Test.GOODBYE);
// added just to show a unit test failure example
export const myFunction = () => Test.HELLO;
Code language: TypeScript (typescript)
And what do you know, that passes the unit test:
console.log
hello
at Object.<anonymous> (src/index.ts:6:9)
console.log
goodbye
at Object.<anonymous> (src/index.ts:7:9)
PASS src/index.test.ts
index
✓ should pass, right? (2 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.026 s
Ran all test suites.
Code language: Shell Session (shell)
But why?
Well, look at the compiled code:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.myFunction = exports.Test = void 0;
var Test;
(function (Test) {
Test["HELLO"] = "hello";
Test["GOODBYE"] = "goodbye";
})(Test || (exports.Test = Test = {}));
console.log(Test.HELLO);
console.log(Test.GOODBYE);
// added just to show a unit test failure example
const myFunction = () => Test.HELLO;
exports.myFunction = myFunction;
Code language: TypeScript (typescript)
This time our enum
definition was converted into a rather funky immediately invoked function expression (IIFE) that sets up the value of the Test
variable.
This is the behaviour I expected, after all the TypeScript docs themselves confirm we are able to use Enums at runtime:
Clearly then, this was some misunderstanding about type definitions / type roots on my part.
I think this is because for personal projects I do not create .d.ts
files for my own code. I create a file like types.ts
, or often export
types directly from the files that are the only place that the defined types are used.
It was my understanding that type definition files aren’t required, unless you are publishing your code for use by other people (as in, a library or module), or you are trying to type an untyped third party library. Fortunately for most commonly used libraries, some kind soul will have already done the dirty work for you – see definitely typed (any references in your package.json
to @types/...whatever
)
Can I Export Const The Enum?
Enums in TypeScript are complicated. So much so that I tend to shy away from using them, truth be told.
Here’s one such way in which I find them more complicated than they seemingly ought to be.
What if, in our some-types.d.ts
file, we updated the export
to, instead, be export const
:
// src/@types/some-types.d.ts
export const enum Test {
HELLO = "hello",
GOODBYE = "goodbye",
}
Code language: TypeScript (typescript)
And then used that once more in our code:
import { Test } from "./@types/some-types";
console.log(Test.HELLO);
console.log(Test.GOODBYE);
// added just to show a unit test failure example
export const myFunction = () => Test.HELLO;
Code language: JavaScript (javascript)
Well, now that still passes the unit tests, but the compiled JS is changed significantly:
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.myFunction = void 0;
console.log("hello" /* Test.HELLO */);
console.log("goodbye" /* Test.GOODBYE */);
// added just to show a unit test failure example
const myFunction = () => "hello" /* Test.HELLO */;
exports.myFunction = myFunction;
Code language: JavaScript (javascript)
What’s happened here?
TypeScript has inlined our definitions. It has taken the enum usages – Test.HELLO
etc – in our TypeScript code, and replaced the line with the literal string value in the compiled JS.
This is considered a space saving optimisation – useful if you are publishing a module to npm
and want to keep your bundle size as small as possible.
But do read that link above, as it comes with a whole load of caveats. And once you start telling me about ambient const enums … well, I’m afraid I simply don’t understand, nor feel I need too.
The answer is already simple for me: don’t even bother.
Which leads me on to a second solution…
Two Files, Same Name (Kind Of)
Once I had figured out that I could extract the enum
from the .d.ts
file and it would work normally, I had a second problem…
Well, where should it live?
The file, let’s called it src/@types/some-types.d.ts
was actually a pretty descriptive name.
Coming up with a better name that I could use just to export this enum
wasn’t immediately obvious.
However, the solution (or a solution) is that you can re-use the name.
In the same directory it is perfectly viable to have:
some-types.d.ts
some-types.ts
And actually, you don’t even need to update the import
to use this.
Feels odd, but is a viable solution.
Conclusion
I did already put the conclusion I came to back in the intro.
My take is: don’t put enum
in a .d.ts
file, ever.
And secondly, I wouldn’t go around the houses with these hacks above to try to make use of enum
.
Put enum
is a regular .ts
file and export
it.
Otherwise, there may be alternative solutions that better meet your needs.