Fable: 改进联合的 TypeScript 类型

创建于 2020-06-18  ·  31评论  ·  资料来源: fable-compiler/Fable

描述

当前为联合生成的 TypeScript 定义使它们难以正确使用或使用 TypeScript 构建。

一个例子:

type Msg =
| ChangeValue of value: string
| Nothing
| Something of value: int * name: string

编译为:

export class Msg extends Union {
  constructor(tag: int32, name: string, ...fields: Array<any>) {
    super(tag, name, ...fields);
  }
}

或禁用类类型的近似等价物。

我一直在修改这个输出和 fable-library 类型,并且我已经能够将 fable-library 中的一些附加定义与关于生成的联合类型的更明确的类型信息结合起来,使 TypeScript 能够安全地使用和构造 Fable 生成联合类型而不改变对象结构。 它并不完美,它在编译输出中为每个联合类型创建了一个额外的类型声明,我想找到一种方法来避免这种情况,但我认为在这里分享以供讨论已经足够了。

我的目标是:

  • TypeScript 应该能够正确表示每个联合案例的构造函数签名
  • TypeScript 应该能够使用 switch/if 语句将联合实例缩小到特定情况
  • TypeScript 用户应该不需要知道标签和案例标签之间的映射
  • JavaScript 对象需要保持相同的形状
  • 尽可能少地污染范围

向 fable-library Types.ts 添加了四个额外的声明:

// Provide an interface that supports declaring F# union cases using the form:
// {
//   0: ["label0", [types, of, fields]],
//   1: ["label1", [types, of, fields]],
// }
interface UnionCases {[tag: number]: [string, any[]]}

// Convert a union case declaration as above into the form:
// { tag: 0; label: "label0" } | { tag: 1; label: "label1" }
// This is then used in FSharpUnionType to type static members mapping case labels to their tag
type TagLabels<Cases extends UnionCases> = {[Tag in keyof Cases]: {tag: Tag; label: Cases[Tag & number][0]}}[keyof Cases]

// Given a shape type as above, produce Union<Cases, 0> | Union<Cases, 1>
export type FSharpUnionCase<Cases extends UnionCases> = {[Tag in keyof Cases]: Union<Cases, Tag & number>}[keyof Cases]

// Given a shape type as above, produce the following:
export type FSharpUnionType<Cases extends UnionCases> = {
  // A construct signature, generic on Tag, that narrows name to the string literal representing the case label and subsequent parameters to the case fields
  // It does _not_ return an instance narrowed to the specific case constructed
  new<Tag extends (keyof Cases & number)>(tag: Tag, name: Cases[Tag][0], ...fields: Cases[Tag][1]): FSharpUnionCase<Cases>
}
// Static members: { "label0": 0; "label1": 1 }
& {[Label in TagLabels<Cases>['label']]: Extract<TagLabels<Cases>, {label: Label}>['tag']}

然后在 UnionCases 和可能的 Tag 值的扩展上使 Union 通用:

export class Union<Cases extends UnionCases = UnionCases, Tag extends (keyof Cases & number) = (keyof Cases & number)> extends SystemObject implements IComparable<any> {
  public readonly tag: Tag;
  public readonly name: Cases[Tag][0];
  public readonly fields: Cases[Tag][1];

  constructor(tag: Tag, name: Cases[Tag][0], ...fields: Cases[Tag][1]) {
    super();
    this.tag = tag;
    this.name = name;
    this.fields = fields;
  }
...

这里, keyof Cases & number将 Tag 限制为 UnionCases 的索引签名,我为这两个泛型参数提供了默认值以简化生成的代码。

然后我将上面的 Msg 声明改写为:

// this could collide with other valid f# types - not sure what the solution is
// maybe a computed type that extracts this shape from an FSharpUnionCase<> type
type MsgCases = {
  0: ["ChangeValue", [string]],
  1: ["Nothing", []],
  2: ["Something", [int32, string]]
}

type Msg = FSharpUnionCase<MsgCases>
const Msg: FSharpUnionType<MsgCases> = (function() {
  return class Msg extends Union {
    static ChangeValue: 0
    static Nothing: 1
    static Something: 2
    constructor(tag: keyof MsgCases, name: MsgCases[keyof MsgCases][0], ...fields: MsgCases[keyof MsgCases][1]) {
      super(tag, name, ...fields);
    }
  } as FSharpUnionType<MsgCases>
}())

export { Msg }

使用此更新的定义,只要指定了标签,TypeScript 语言服务就会缩小 Msg 构造函数的范围:
let msg = new Msg(Msg.ChangeValue,

const Msg: new <0>(tag: 0, name: "ChangeValue", fields_0: string) => Union<MsgCases, 0> | Union<MsgCases, 1> | Union<MsgCases, 2>

构造完成后,就可以使用普通的 switch 语句对其进行测试:

let msg = new Msg(Msg.ChangeValue, "ChangeValue", "string")

switch (msg.tag) {
  case Msg.ChangeValue:
  {
    let a: string;
    [a] = msg.fields;
    let len: 1 = msg.fields.length;
    break;
  }
  case Msg.Nothing:
  {
    let len: 0 = msg.fields.length;
    break;
  }
  case Msg.Something:
  {
    let a: number, b: string;
    [a, b] = msg.fields;
    let len: 2 = msg.fields.length;
    break;
  }
  default: 
    assertNever(msg);
}
function assertNever(_: never) {}

我希望这是有用的。 我很乐意帮助实现这些方面的东西,尽管我可能需要一些指示从哪里开始。

discussion

最有用的评论

好吧,在阅读了比任何理智的人都多的 v8 设计文档,并使用本机 linux perf跟踪运行一些实验后,我很高兴地报告我有一个 master 的衍生物,其中 classtypes 降低到大约 5% 的开销与工厂相比,使用time进行端到端测量。 我将打开一个单独的讨论问题的细节,这样我们就不会进一步脱轨:)

所有31条评论

当前为联合生成的 TypeScript 定义使它们难以正确使用或使用 TypeScript 构建。

@chrisvanderpennen你介意详细说明一下,这样我就可以更好地理解用例吗? 我知道联合字段现在是无类型的,但你是在谈论寓言生成的 TS 和其他 TS 之间的交互,还是只是更好看的生成 TS? 如果是为了交互,我们如何在 JS 中使用联合进行相同的交互,为什么我们在 TS 中需要不同的方式,即使我们可以?

我想我想了解的是,为联合生成的反射信息是否足以简化与联合的交互,还是需要扩展?

这是关于允许 TS 编译器验证 Fable 生成的联合 TS 和其他手写 TS 之间的正确交互。 我想到的主要用例是使用 Fable 交叉编译类库,以便在 .NET 服务和现有 TS 浏览器应用程序中使用。

就目前而言,记录类型和函数的类型非常好,但对于联合,TS 开发人员需要参考 F# 类型定义,并且 TS 编译器无法验证其代码是否正确。

此处的附加类型信息使 TS 编译器能够查看有效联合案例及其字段。 然后,TS 编译器可以在编译时验证与 Fable 联合的正确交互,并且在构造联合实例或与联合实例交互时,语言服务会显示案例和字段元组的详细类型信息。

此处的类型更改不会更改联合实例的运行时表示。 它们确实向表示有效案例的联合类添加了额外的静态属性,但这些属性不能与联合上的其他 F# 定义的静态成员发生冲突,因为声明与案例标签冲突的静态成员是非法的。

如果有帮助,我可以获得一些屏幕截图,显示在使用当前和建议的类型定义的 vscode 中的样子。

既然你提到了它,我也许可以使用反射函数的返回类型而不是额外的形状定义类型。 我稍后会尝试并报告。

@chrisvanderpennen感谢您的澄清。 一些随机观察:

  • 静态标签标签确实会增加对象的大小,但可能没问题,我们只能在启用 typescript 标志的情况下添加它们。 顺便说一句,它们确实需要初始化:
  static ChangeValue: 0 = 0;
  static Nothing: 1 = 1;
  static Something: 2 = 2;
  • 构造函数仍然不能防止将标签与错误的名称或类型的参数混合,尽管可能的集合要小得多。 我想知道是否可以在这里强制执行正确性:
let msg = new Msg(Msg.Something, "Nothing");

顺便说一句,它们确实需要初始化:

哎呀:脸红:

我相信 TypeScript 编译静态的方式使它们远离原型,它们被直接分配给类对象。 我会确认的。

构造函数仍然不能防止将标签与错误的名称或类型的参数混合

如果我在本地尝试,我会收到编译错误,一旦传递了标签,name 参数就会缩小到适当的文字。 我可能错过了示例代码中的某些内容。

编辑:特别是我测试的“Nothing”错误与“Something”不兼容,并且该函数需要 4 个参数,但给出了 2 个。

@chrisvanderpennen Nvm,那是我试图简化类声明。 当类 decl 被强制转换为FSharpUnionType<MsgCases>时,构造函数按预期工作。

类声明大致class extends Union<MsgCases, 0 | 1 | 2> ,不幸的是 TS 没有缩小泛型参数的范围。 转换是必要的,以将其转换为Union<MsgCases, 0> | Union<MsgCases, 1> | Union<MsgCases, 2> ,TS 会很高兴地缩小范围。

@chrisvanderpennen什么时候没有使用类,那里没有变化?

我_认为_它大致相同,类似于export Msg: FSharpUnionType<MsgCases> = declare(...)但我还没有仔细研究它。 名单上的下一个:)

@chrisvanderpennen一般来说,LGTM 只需要弄清楚这是应该进入当前版本还是下一个版本,因为 Fable v.next 的工作已经由 @alfonsogarciacaro 开始。

这适用于不使用类的情况,但我必须从 Union 的字段中删除readonly修饰符:

export type Msg = FSharpUnionCase<MsgCases>
export const Msg: FSharpUnionType<MsgCases> = declare(function App_View_Msg(
  this: Msg,
  tag: keyof MsgCases,
  name: MsgCases[keyof MsgCases][0],
  ...fields: MsgCases[keyof MsgCases][1]
) {
  this.tag = tag; // previously tag | 0, which widens to number
  this.name = name;
  this.fields = fields;
},
Union);
Msg.ChangeValue = 0;
Msg.Nothing = 1;
Msg.Something = 2;

我还可以确认这些静态不会污染实例或原型:

// output from tsc
export const Msg = (function () {
    var _a;
    return _a = class Msg extends Union {
            constructor(tag, name, ...fields) {
                super(tag, name, ...fields);
            }
        },
        _a.ChangeValue = 0,
        _a.Nothing = 1,
        _a.Something = 2,
        _a;
}());

仍在努力寻找不需要 MsgCases 声明的方法。

@chrisvanderpennen查看 #2100 中的更改,希望除了从构造函数中删除标记名称之外不会产生太大影响。

是的,这应该是所有需要的。 我想用它来简化 UnionCases 形状,但如果没有名称,我们将无法正确键入静态。 即使我们将 case() 的结果作为文字元组,元组上的keyof返回字符串文字而不是数字文字——“0”、“1”、“2”等——所以我们不能使用它来正确键入静态值。 至少所有类型信息在通过 tsc 后都会被删除,并且缩小的静态不应该对包增加太多。

对于keyof限制,我已经看到了很多丑陋的解决方法,这些限制基本上由type TupleIndexMap = {"0": 0, "1": 1, "2": 2, ... "99": 99}但是糟糕...

抱歉,这花费的时间比我希望的要长,但我想我有一个可行的解决方案。

我已经更改了 FSharpUnionType 的定义,以便而不是引用 FSharpUnionCase,构造签名的返回类型定义是内联的。 然后我能够将 FSharpUnionCase 重新定义为从 FSharpUnionType 中提取实例签名的条件类型。

// Given a shape type as above, produce the following:
export type FSharpUnionType<Cases extends UnionCases> = {
  // A construct signature, generic on Tag, that narrows name to the string literal representing the case label and subsequent parameters to the case fields
  // Return type is Union<Cases, 0> | Union<Cases, 1>
  new<Tag extends (keyof Cases & number)>(tag: Tag, name: Cases[Tag][0], ...fields: Cases[Tag][1]): {[Tag in keyof Cases]: Union<Cases, Tag & number>}[keyof Cases]
}
// Static members: { "label0": 0; "label1": 1 }
& {[Label in TagLabels<Cases>['label']]: Extract<TagLabels<Cases>, {label: Label}>['tag']}

// Extract the instance signature of an FSharpUnionType<>
export type FSharpUnionCase<TUnion> = TUnion extends FSharpUnionType<infer _> ? InstanceType<TUnion> : never

有了这些更新的定义,案例形状可以在 IIFE 内移动,这样它就不会污染模块范围:

export const Msg = (function() {
  type Msg$Cases = {
    0: ["ChangeValue", [string]],
    1: ["Nothing", []],
    2: ["Something", [int32, string]]
  }
  return class Msg extends Union {
    static ChangeValue: 0 = 0
    static Nothing: 1 = 1
    static Something: 2 = 2
    constructor(tag: keyof Msg$Cases, name: Msg$Cases[keyof Msg$Cases][0], ...fields: Msg$Cases[keyof Msg$Cases][1]) {
      super(tag, name, ...fields);
    }
  } as FSharpUnionType<Msg$Cases>
}())
export type Msg = FSharpUnionCase<typeof Msg>

@chrisvanderpennen太好了,谢谢。 现在在next分支中有相当多的代码改动,所以可能需要更长的时间才能进入。

我也一直在阅读这篇关于在 TypeScript 中使用

可能相关: https :

啊! 通用联合的构造函数端类型中断:(我会看看我是否可以修复它

我一直在努力输入 Result 并且我有一些工作,只是有点麻烦。 我在 FSharpUnionType 上内联了构造函数声明,并将联合的通用参数添加到内联定义中。 不幸的是,因为 TypeScript 的泛型参数推断要么全有要么全无,这意味着调用者需要将标记设置为通用参数和函数参数,以缩小构造函数类型:

new Result<number, string, 0>(0, "Ok", 1234);

如果在 Fable 3 的卡片上扩展发出的 JavaScript/TypeScript,也许更多类似 C# helper 属性的东西会更有用,启用编译器选项,因为显然如果整个应用程序是 Fable 这些不是必要的:

type Result$Cases<_T, _U> = {
    0: ["Ok", [_T]]
    1: ["Error", [_U]]
}

export class Result<_T, _U> extends Union<Result$Cases<_T, _U>> {
    IsOk(): this is Union<Result$Cases<_T, _U>, 0> { return this.tag === 0; }
    IsError(): this is Union<Result$Cases<_T, _U>, 1> { return this.tag === 1; }
    static Ok<_T, _U>(...args: Result$Cases<_T, _U>[0][1]) { return new Result<_T, _U>(0, "Ok", ...args) }
    static Error<_T, _U>(...args: Result$Cases<_T, _U>[1][1]) { return new Result<_T, _U>(1, "Error", ...args) }
    constructor(tag: keyof Result$Cases<_T, _U>, name: Result$Cases<_T, _U>[keyof Result$Cases<_T, _U>][0], ...fields: Result$Cases<_T, _U>[keyof Result$Cases<_T, _U>][1]) {
        super(tag, name, ...fields)
    }
}

好吧,这是一个错误的点击,对不起!

@chrisvanderpennen谢谢,非常感谢。 我认为扩展生成的定义不会有问题,但仍在等待查看没有 Babel 的 JS/TS 发射在 Fable 3 中会如何。

@ncave我有一个关于寓言 3 中的 Babel 的问题。如果我们不打算使用 babel,那是否意味着我们不能再使用 babel 插件和预设了? 例如,babel 根据使用情况在需要时注入 polyfills,并允许自定义编译最终的 JS

@Zaid-Ajaj 请参阅 #2109 中的讨论。 如果你需要的话,我看不出有什么理由不能在 Fable 之后使用 Babel 插件和预设,但如果你有这样的情况,请添加到 #2109。

@chrisvanderpennen不要以任何方式减少您提案中的出色工作,但这是我想讨论的另一种方法,即通过为每个联合案例生成单独的类来完全消除问题:

// for plain JavaScript, we can use a wrapper object as namespace
const MyUnion = {
    Case1: class Case1 { constructor(tag, num) { this.tag = tag; this.num = num; } },
    Case2: class Case2 { constructor(tag, str) { this.tag = tag; this.str = str; } },
};
// for TypeScript, we can use a namespace (which gets compiled to IIFE)
namespace MyUnion {
    export class Case1 { constructor(public tag: 1, public num: number) {} }
    export class Case2 { constructor(public tag: 2, public str: string) {} }
}
type MyUnion = MyUnion.Case1 | MyUnion.Case2;

function getUnionCaseName(unionCase: MyUnion) {
    return unionCase.constructor.name; // no need to keep around case names
}

function getValue(unionCase: MyUnion): number | string {
    switch (unionCase.tag) {
        case 1: return unionCase.num;
        case 2: return unionCase.str;
    }
}

const u1 = new MyUnion.Case1(1, 123);
const u2 = new MyUnion.Case2(2, "abc");

console.log(getValue(u1)); // 123
console.log(getValue(u2)); // abc

console.log(getUnionCaseName(u1)); // Case1
console.log(getUnionCaseName(u2)); // Case2

你怎么认为?

显然,可能存在一些缺点,例如,如果存在自定义相等性的覆盖,则可能会出现一些代码重复,但是可以通过生成一次并在所有联合情况下引用它们来最小化这些。

老实说,如果您愿意接受破坏性更改,那比我笨拙地尝试添加类型而不更改运行时表示要简洁得多。 没有共同的基类会阻止foo instanceof MyUnion但我不确定这会造成多大损失。

扯掉你的建议,怎么样:

// --- fable.library stubs ---

abstract class Union {
    public abstract tag: number;
    public abstract fields: any[];
    // Equals
    // GetHashCode
    // etc
}

function getUnionCaseName(unionCase: Union) {
    return unionCase.constructor.name; // no need to keep around case names
}

// --- Generated code ---

namespace Result {
    abstract class Result<TOk, TError> extends Union {
        public IsOk(): this is Result.Ok<TOk, TError> { return this.tag === 1; }
        public IsError(): this is Result.Error<TOk, TError> { return this.tag === 2; }
    }

    export class Ok<TOk, TError> extends Result<TOk, TError> {
        tag = 1 as const;
        fields; // inferred below - (property) Result<TOk, TError>.Ok<TOk, TError>.fields: [ok: TOk]
        constructor(...fields: [ok: TOk]) { super(); this.fields = fields; } // named tuple elements are a thing now :D
    };

    export class Error<TOk, TError> extends Result<TOk, TError> {
        tag = 2 as const;
        fields; // inferred below - (property) Result<TOk, TError>.Error<TOk, TError>.fields: [error: TError]
        constructor(...fields: [error: TError]) { super(); this.fields = fields; }
    };
}

type Result<TOk, TError> = Result.Ok<TOk, TError> | Result.Error<TOk, TError>;

// --- Usage ---

// constructor completion: Ok(ok: number): Result.Ok<number, string>
let succeed = function (): Result<number, string> { return new Result.Ok<number, string>(1); }

// constructor completion: Error(error: string): Result.Error<number, string>
let fail = function (): Result<number, string> { return new Result.Error<number, string>("bar's error"); }

let success = succeed();
let failure = fail();

switch (success.tag) {
    case 1:
        // fields tooltip: (property) Result<TOk, TError>.Ok<number, string>.fields: [ok: number]
        let [n]: [number] = success.fields;
        console.log(n); // log: 1
        break;
    case 2:
        // fields tooltip: (property) Result<TOk, TError>.Error<number, string>.fields: [error: string]
        let [e]: [string] = success.fields;
        console.log(e);
        break;
}

switch (failure.tag) {
    case 1:
        let [n]: [number] = failure.fields;
        console.log(n);
        break;
    case 2:
        let [e]: [string] = failure.fields;
        console.log(e); // log: bar's error
        break;
}

if (success.IsOk()) {
    let [n]: [number] = success.fields;
    console.log(n); // log: 1
}

if (success.IsError()) {
    let [e]: [string] = success.fields;
    console.log(e); // this branch isn't hit
}

if (failure.IsOk()) {
    let [n]: [number] = failure.fields;
    console.log(n); // this branch isn't hit
}

if (failure.IsError()) {
    let [e]: [string] = failure.fields;
    console.log(e); // log: bar's error
}

console.log(getUnionCaseName(success)); // log: Ok
console.log(getUnionCaseName(failure)); // log: Error

@chrisvanderpennen出于性能原因,我试图避免为联合使用基类(请参阅#2153)。

等什么

如果不是太麻烦,您介意发布我如何重现您的结果吗? 我想尝试深入了解。

@chrisvanderpennen假设您询问基准测试结果,它们来自使用 Fable-JS 编译 Fable:

git clone fable_repo
cd Fable
npm install

git checkout some_branch
rm -rf build
npm run build compiler

cd src/fable-standalone/test/bench-compiler
rm -rf dist
rm -rf out-node
rm -rf out-node2

npm run build-dotnet    (or `npm run build-dotnet-cls` to compile with classes)
npm run build-node    (the FCS running time is logged at the second output line)

Some node.js profiling is available, but inconclusive, at least to me:

npm run profile
npm run prof-process (modify to use the actual isolate file name)

另请参阅 #2056 中的一些统计数据。

一些结果(特定于机器):

  • master 分支(无课程),FCS 时间:59 秒
  • master 分支(使用 --classTypes),FCS 时间:83 秒
  • 流山(带课程)+ #2150,FCS 时间:87 秒
  • 流山(带课程)+ #2153,FCS 时间:80 秒

我宁愿避免在每个联合案例中发出一个类。 这意味着 Elmish 应用程序的许多代码都集中使用联合类型来表示与 UI 事件对应的消息。 是否可以在名称上使用 Typescript 对象联合来具有一些模式匹配功能? 喜欢:

class MyUnionImpl {
  constructor(public tag: number, public fields: [any]) {

  }
  get name() {
    return ["Foo", "Bar"][this.tag];
  }
}

type MyUnion = {
  name: "Foo",
  fields: [number]
} | {
  name: "Bar",
  fields: [string]
};

function test(x: MyUnion): number {
  if (x.name === "Foo") {
    const i = x.fields[0];
    return i + 5;
  } else {
    const s = x.fields[0];
    return s; // Compilation error, this is a string
  }
}

test(new MyUnionImpl(0, [1]) as MyUnion);

@alfonsogarciacaro当然,虽然我认为不会有更多的代码,基本上只是每个案例的构造函数。
无论如何,这只是一个讨论点,我们将坚持此 PR 的现有工会代表。

好吧,在阅读了比任何理智的人都多的 v8 设计文档,并使用本机 linux perf跟踪运行一些实验后,我很高兴地报告我有一个 master 的衍生物,其中 classtypes 降低到大约 5% 的开销与工厂相比,使用time进行端到端测量。 我将打开一个单独的讨论问题的细节,这样我们就不会进一步脱轨:)

由于寓言 3 中工会的设计已经确定,因此暂时关闭。 如果需要讨论下一个主要版本中生成代码的性能,请重新打开(或打开一个新问题)。 非常感谢@chrisvanderpennen @ncave 的深刻见解!

此页面是否有帮助?
0 / 5 - 0 等级