Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Emulating "enums" in JSDoc version of TypeScript

Emulating "enums" in JSDoc version of TypeScript

Problem

TypeScript has support for type-checking plain JavaScript files, which is very useful if you have an existing JS codebase and you want to test the waters and gradually add types.

There are some limitations in what you can do in JSDoc, but a lot of them can be worked-around by using type-definition files .d.ts (for example in a types/ directory). These files don't generate any JavaScript code, they are just there to provide extra type definitions to the compiler.

One thing you can't do in those .d.ts files though, is use enums. You could define them of course, but you won't get the runtime representation since the files don't generate JS code.

Solution

The solution I found requires a bit more boilerplate and is more error-prone than the pure TypeScript version, but it seems to work.

Instead of defining an enum in your type definition file, you define both a union type and an interface:

// types/models.d.ts

declare namespace Models {
  type ProductTag = "popular" | "featured" | "sale";
  interface ProductTagEnum {
    Popular: "popular";
    Featured: "featured";
    Sale: "sale";
  }

  interface Product {
    id: string;
    name: string;
    tags: Array<ProductTag>;
  }
}

Then you create a runtime representation of your "enum" using the interface. You can use this representation elsewhere in your code.

// app/models/product.js
// @ts-check

/** @type {Models.ProductTagEnum} */
const ProductTag = {
  Popular: "popular",
  Featured: "featured",
  Sale: "sale"
};

/**
 * @param {Models.Product} product
 * @returns {boolean}
 */
function isPromoted(product) {
  return (
    product.tags.indexOf(ProductTag.Featured) >= 0 &&
    product.tags.indexOf(ProductTag.Sale) >= 0
  );
}

Caveats

  • There is more boilerplate because you basically have to define the enum 3 times: the union type, the interface, and the runtime const.
  • It is more error-prone because the compiler won't check that the union type and the interface are "in sync" (but you'll probably get an error when you try to use your const).
  • You don't have to define the runtime const, you could just use a string directly (ex: product.tags.indexOf("featured")), but it makes it harder to track down where you are using your enum values (ex: can't use your IDE's "find usages" feature, need to search for the string which could show up in comments etc.)

Appendix

// tsconfig.json

{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "jsx": "react",
    "module": "commonjs",
    "moduleResolution": "node",
    "noEmit": true,
    "strict": true,
    "target": "es6"
  },
  "include": [
    "app/**/*",
    "types/**/*"
  ]
}
@xxapp
Copy link

xxapp commented Sep 9, 2020

Thank you, this solution saved my time.

And I made a little improve:

interface ProductTagEnum {
  Popular: "popular";
  Featured: "featured";
  Sale: "sale";
}
type ProductTag = ProductTagEnum[keyof ProductTagEnum];

@fabd
Copy link

fabd commented Aug 26, 2021

Sadly we can't do const assertions in JSDoc

https://fettblog.eu/tidy-typescript-avoid-enums/

Have you found something more elegant since?

@fabd
Copy link

fabd commented Aug 26, 2021

Looks like this may be fixed soon ?

microsoft/TypeScript#30445

@fabd
Copy link

fabd commented Aug 26, 2021

In the mean time, not sure how this compares (too tired rn):

In a .d.ts file I declare the enum (convention add E at end of the name), and a type that is the literal values of that enum:

type Values<T> = T[keyof T];

type RuneTierE = {
  COMMON: 1;
  SEMIRARE: 2;
  RARE: 3;
};

type RuneTier = Values<RuneTierE>;

I declare that enum const in some other file (eg. '@/data/runes.js'):

/** @type {RuneTierE} */
export const RuneTierE = {
  COMMON: 1,
  SEMIRARE: 2,
  RARE: 3,
};

Can use it like so (vscode correctly auto-completes RuneTierE.):

/** @type {{name: RuneId, tier: RuneTier}[]} */
const runes = [
  { name: "El", tier: RuneTierE.COMMON },
  { name: "Eld", tier: RuneTierE.COMMON },

In the consumer files I import the enum

import { RuneTierE } from "@/data/runes";

/** @param {RuneTier} tier */
function isRareExampleFunc(tier) { return tier === RuneTierE.RARE; }

When I hover RuneTier param in VSC it shows the literal values : type RuneTier = 1 | 2 | 3

I guess a small iteration over your implmentation by using the tip in above articles to create the union of the enum's values with type Values<T> = T[keyof T]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment