Typescriptr icon indicating copy to clipboard operation
Typescriptr copied to clipboard

Allow enums to be exported

Open xwipeoutx opened this issue 6 years ago • 6 comments

Currently enums are generate as

enum MyEnum {
  One = "One",
  Two = "Two"
}

Which means I can't reference the types in my code - they're not exported.

I hacked a quick .Replace("enum ", "export enum ") for my purpose, but should be slapped into the library when possible.

xwipeoutx avatar Nov 22 '18 03:11 xwipeoutx

So I've delved a bit more into this and realised the types defined in the model ("Value1" | "Value2") are not actually assignable to the enum - only comparable.

See This playground entry

enum Foo {
    A = "Value1",
    B = "Value2"
}

interface Model {
    foo: "Value1" | "Value2"
}

var model: Model = {
     foo: Math.random() > 0.5 ? "Value1" : "Value2"
};

if (model.foo === Foo.A) {
    // this is fine
}

function doAThing(input: Foo) {}

doAThing(model.foo); // this is not
doAThing(Foo[model.foo]); // nor is this
doAThing(model.foo as Foo); // this is fine

var attempt1: Foo = model.foo; // not fine
var attempt2: Foo = Foo[model.foo]; // not fine
var whelpGuessImCastingNow: Foo = model.foo as Foo; // fine

Not sure what this means for your library, whether this is in the scope of it or not, but that's what's happened to me :)

xwipeoutx avatar Nov 22 '18 05:11 xwipeoutx

Hey Steve, thanks for the issue! Surprised I haven't run into this one yet, but great to catch it now.

Hmm, this seems to be a pretty big limitation of typescript enums. I found this thread on the topic.

It links to a proposed workaround that suggests not using TS enums at all, but to generate a type based on the keyof of an object or namespace.

We could change the generation of enums to, instead of TS enums, generate types like the workaround. Your playgound script would then become this.

Note that you still wouldn't be able to do Foo[model.foo] as there's no lookup on the object, but model.foo would now be assignable to Foo as the type is a string union instead of an enum.

This wouldn't be a breaking change either, as we'd be expanding the allowed usages of the generated enums. We'd also add the export to ensure they're globally available :).

gkinsman avatar Nov 22 '18 12:11 gkinsman

Hey Steve, not sure if it will work for your use case, but I got around something similar by wrapping the enums in the same namespace as the types and using EnumFormatter.ValueNamedEnumFormatter.

// enums.ts
declare namespace Api {
  enum Foo {
      A = 'Value1',
      B = 'Value2'
  }
}

// types.d.ts
declare namespace Api {
  interface Model {
      foo: Foo;
  }
}

// elsewhere
const model: Api.Model = {
  foo: Math.random() > 0.5 ? Api.Foo.A : Api.Foo.B
};

if (model.foo === Api.Foo.A) {
    // this is fine
}

function doAThing(input: Api.Foo) {}

doAThing(model.foo); // we're ok now
doAThing(Api.Foo[model.foo]); // happy days
doAThing(model.foo as Api.Foo); // this is fine

const attempt1: Api.Foo = model.foo; // fine
const attempt2: Api.Foo = Api.Foo[model.foo]; // fine
const whelpGuessImCastingNow: Api.Foo = model.foo as Api.Foo; // fine

emilol avatar Nov 24 '18 07:11 emilol

So, I've had a bit of a play with our options here.

It seems that in a modern, correct tsconfig setup, there's no need to explicitly reference the enum.ts file from the types.d.ts file when using the enum types directly in the types file. Because they're included together (regardless of if they share namespace or not), it just works.

Rendering string unions as we're currently doing for properties causes the problem in the second comment. I'm unsure what drove the initial decision to render string unions - I think it was trouble referencing the files in a problematic tsconfig setup, or maybe an old version of TypeScript.

If we change the types file to render the actual enum type names, then we should be out of the woods. Are there any situations you've encountered in which this isn't the case? There's currently no out of the box facility to render them like this - it would require providing a custom FormatEnumProperty delegate to render properties just by their name. Namespacing enums make this a little tricker as we'd need to include that, but no big deal.

gkinsman avatar Nov 25 '18 12:11 gkinsman

Thanks for the feedback @emilol and @gkinsman - sorry took me so long to respond, it's been crazy times.

I'm not a massive fan of having namespaces in non-declaration files, but it looks like it solved the problem simply for you, so that's cool!

I found myself wanting both the names and the numbers (for interop with Unity's JSON serializer and display reasons), and thus being able to use Enum Genie was pretty tempting. To that end I've done up a bit of a recipe so that Typescriptr and EnumGenie play nicely together.

It's a bit of hack, I'll admit (I'm doing some string replaces and have custom enum formatters in TypeScriptr) but it solved my problem well.

The basic idea is slapping both the enums and the types in the same file, using the enum's typenames in Typescriptr and using EnumGenie for its magic.

You can find it on the EnumGenie wiki, or here it is copy/pasted:

var generator = TypeScriptGenerator.CreateDefault()
    .WithEnumFormatter(EnumFormatter.ValueNumberEnumFormatter, (type, style) => type.Name)
    .WithTypeMembers(MemberType.PropertiesAndFields);

var typesToGenerate = 
    typeof(IAmWebApi).Assembly.ExportedTypes
        .Where(type => type.GetCustomAttribute<ClientTypeAttribute>() != null);

var result = generator.Generate(typesToGenerate);

const string rootPath = "../../../webclient/src/models/";

using(var fs = File.Create(Path.Combine(rootPath, "types.d.ts")))
using(var tw = new StreamWriter(fs)) {
    tw.Write(result.Enums.Replace("enum", "declare enum") + result.Types);
}

new EnumGenie.EnumGenie()
    .SourceFrom.Assembly(typeof(IAmWebApi).Assembly)
    .WriteTo.File(Path.Combine(rootPath, "enums.ts"), cfg => cfg.TypeScript())
    .Write();

Which generates something like

types.d.ts

declare enum Foo {
  Value1 = 1,
  Value2 = 2
}
declare namespace Api {
    declare type Model {
      foo: Foo
    }
}

enums.ts

enum Foo {
  Value1 = 1,
  Value2 = 2
}
// etc... standard EnumGenie stuff

This approach should work pretty well even without enum genie - though as you say, you gotta be careful with enums in .d.ts files - so I'd still recommend duplicating them in a second enums.ts file, whether with Typescriptr or EnumGenie

As far as typescriptr changes to support this flow goes, you would need to

  1. Allow a "combined" output that does declare enum in the .d.ts
  2. Add an "enum name only" formatter for enums
  3. Update the docs to clarify the enums being duplicated in .api.d.ts and enums.ts

xwipeoutx avatar Dec 10 '18 11:12 xwipeoutx

If we change the types file to render the actual enum type names, then we should be out of the woods. Are there any situations you've encountered in which this isn't the case?

I've recently ended up in a similar situation as the original issue, trying to use the generated types for assignment. Compilation is fine without it, but actually using the enum in the app results in a runtime ReferenceError. Seems to be the case even in a minimal solution with the same tsconfig settings as the playground. e.g.:

var bar = Foo.Value1; // compiles fine, runtime ReferenceError: Foo is not defined

I ended up having to use a hack similar @xwipeoutx's solution.

public GenerationResult Generate()
{
    var generator = TypeScriptGenerator.CreateDefault()
        .WithNamespace(string.Empty)
        .WithEnumFormatter(EnumFormatter.ValueNumberEnumFormatter, (type, style) => type.Name);

    var result = generator.Generate(GetApiTypes(_apiAssembly));

    _enumNames.Clear();

    var types = (result.Types + result.Enums).AddNamespace("Api");
    var enums = result.Enums.Replace("enum", "export enum");

    return new GenerationResult(types, enums);
}

type.d.ts

declare namespace Api {
  declare enum Foo {
    Value1 = 1,
    Value2 = 2
  }
  declare type Model {
    foo: Foo
  }
}

enums.ts

export enum Foo {
  Value1 = 1,
  Value2 = 2
}

The benefit for me of having the enum declaration inside the namespace was that it takes them out of the global declaration, so I get an error at compile time rather than run time.

var bar = Foo.Value1; // compile-time "Cannot find name 'Foo'"
import { Foo } from 'enums.ts';
var bar = Foo.Value1; // working

emilol avatar Jan 15 '19 02:01 emilol