TypeScript类型忽略大小写

30

我在 TypeScript 中有这个类型定义:

export type xhrTypes = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "CONNECT" | "HEAD";

不幸的是,这是大小写敏感的...有没有办法定义为不区分大小写?

谢谢


2
不,那是不可能的。 - Aluan Haddad
1
不行,但是你可以模拟枚举 - andreim
好的,谢谢!但我觉得我会保持它区分大小写。 - marco burrometo
4个回答

28

针对 TypeScript 4.1+ 的新答案

欢迎回来!现在 TypeScript 4.1 已经引入了模板字面量类型Uppercase/Lowercase 内置字符串映射类型, 我们现在可以回答这个问题而不需要使用正则表达式类型。


有两种主要方法。 "暴力"方法大量使用递归条件类型和联合类型,将您的xhrTypes转换为所有可能方式的具体联合类型,其中大小写不敏感:
type xhrTypes = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "CONNECT" | "HEAD";

type AnyCase<T extends string> =
    string extends T ? string :
    T extends `${infer F1}${infer F2}${infer R}` ? (
        `${Uppercase<F1> | Lowercase<F1>}${Uppercase<F2> | Lowercase<F2>}${AnyCase<R>}`
    ) :
    T extends `${infer F}${infer R}` ? `${Uppercase<F> | Lowercase<F>}${AnyCase<R>}` :
    ""


type AnyCaseXhrTypes = AnyCase<xhrTypes>;

如果您检查{{AnyCaseXhrTypes}},您会发现它是一个包含368个成员的联合体:

/* type AnyCaseXhrTypes = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | 
"CONNECT" | "HEAD" | "GEt" | "GeT" | "Get" | "gET" | "gEt" | "geT" | "get" | 
"POSt" | "POsT" | "POst" | "PoST" |  "PoSt" | "PosT" | "Post" | 
... 346 more ... | "head" */

您可以使用此类型替换xhrType,无论何时您需要不区分大小写:

function acceptAnyCaseXhrType(xhrType: AnyCaseXhrTypes) { }

acceptAnyCaseXhrType("get"); // okay
acceptAnyCaseXhrType("DeLeTe"); // okay
acceptAnyCaseXhrType("poot"); // error! "poot" not assignable to big union

暴力解决方案的问题在于,随着字符串数量或长度的增加,它的可扩展性不佳。 TypeScript中的联合类型仅限于100,000个成员,并且递归条件类型最多只能深入约20层,否则编译器会报错。因此,任何稍长的单词或稍长的单词列表都会使上述方法不可行。
type xhrTypes = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "CONNECT" | "HEAD"
 | "LONG STRINGS MAKE THE COMPILER UNHAPPY";

type AnyCaseXhrTypes = AnyCase<xhrTypes>; // error!
// Type instantiation is excessively deep and possibly infinite.
// Union type is too complex to represent

一种解决方法是不再使用具体的联合类型,而是转换为通用类型表示。如果T是传递给acceptAnyCaseXhrType()的字符串值的类型,则我们想要做的就是确保Uppercase<T>可分配给xhrType。这更像是约束而不是类型(尽管我们无法直接使用通用约束来表达此内容):
function acceptAnyCaseXhrTypeGeneric<T extends string>(
    xhrType: Uppercase<T> extends xhrTypes ? T : xhrTypes
) { }

acceptAnyCaseXhrTypeGeneric("get"); // okay
acceptAnyCaseXhrTypeGeneric("DeLeTe"); // okay
acceptAnyCaseXhrTypeGeneric("poot"); // error! "poot" not assignable to xhrTypes

这个解决方案要求你在可能不需要它们的地方拉动通用类型参数,但它确实可以很好地扩展。


所以,我们所要做的就是等待...(查看笔记)... 3年,而TypeScript已经推出!

Playground链接到代码


这太棒了!也许对于这种特定情况有点过头了,但模板文字类型可能非常强大。 - marco burrometo

13

为了回答这篇文章,没有,这是不可能的。

2018年5月15日更新: 仍然不可能。最接近的是使用正则表达式验证字符串类型,但这个提案在最近的语言设计会议上并未得到良好的反响。


2
为了使正则表达式验证的字符串类型建议被广泛接受,需要做出哪些改变? - Ozymandias
请查看 https://github.com/Microsoft/TypeScript/issues/6579。 - Ryan Cavanaugh
哦,是的。 - jcalz

3
正如@RyanCavanaugh所说,TypeScript没有不区分大小写的字符串字面量。[编辑:我想起了一个现有的建议,即支持正则表达式验证的字符串字面量,这可能允许这样做,但目前它不是语言的一部分。]
我能想到的唯一解决方法是列举这些字面量最可能的变体(比如全部小写,首字母大写),并编写一个可以在它们之间进行转换的函数,以便在需要时使用:
namespace XhrTypes {
  function m<T, K extends string, V extends string>(
    t: T, ks: K[], v: V
  ): T & Record<K | V, V> {
    (t as any)[v] = v;
    ks.forEach(k => (t as any)[k] = v);
    return t as any;
  }
  function id<T>(t: T): { [K in keyof T]: T[K] } {
    return t;
  }
  const mapping = id(m(m(m(m(m(m(m({},
    ["get", "Get"], "GET"), ["post", "Post"], "POST"),
    ["put", "Put"], "PUT"), ["delete", "Delete"], "DELETE"),
    ["options", "Options"], "OPTIONS"), ["connect", "Connect"], "CONNECT"),
    ["head", "Head"], "HEAD"));      

  export type Insensitive = keyof typeof mapping
  type ForwardMapping<I extends Insensitive> = typeof mapping[I];

  export type Sensitive = ForwardMapping<Insensitive>;     
  type ReverseMapping<S extends Sensitive> = 
    {[K in Insensitive]: ForwardMapping<K> extends S ? K : never}[Insensitive];

  export function toSensitive<K extends Insensitive>(
    k: K ): ForwardMapping<K> {
    return mapping[k];
  }

  export function matches<K extends Insensitive, L extends Insensitive>(
    k: K, l: L ): k is K & ReverseMapping<ForwardMapping<L>> {
    return toSensitive(k) === toSensitive(l);
  }
}

导出的内容包括以下类型:
type XhrTypes.Sensitive = "GET" | "POST" | "PUT" | "DELETE" | 
  "OPTIONS" | "CONNECT" | "HEAD"

type XhrTypes.Insensitive = "get" | "Get" | "GET" | 
  "post" | "Post" | "POST" | "put" | "Put" | "PUT" | 
  "delete" | "Delete" | "DELETE" | "options" | "Options" |
  "OPTIONS" | "connect" | "Connect" | "CONNECT" | "head" | 
  "Head" | "HEAD"

以及这些功能

 function XhrTypes.toSensitive(k: XhrTypes.Insensitive): XhrTypes.Sensitive;

 function XhrTypes.matches(k: XhrTypes.Insensitive, l: XhrTypes.Insensitive): boolean;

我不确定你(@Knu)需要这个做什么或者怎样使用它,但我想象你可能想要在敏感和不敏感的方法之间进行转换,或者检查两个不区分大小写的方法是否匹配。显然你可以通过将字符串转为大写或执行不区分大小写比较,在运行时实现这些功能,但在编译时上述类型可能会有用。
以下是一个使用示例:
interface HttpStuff {
  url: string,
  method: XhrTypes.Insensitive,
  body?: any
}
const httpStuff: HttpStuff = {
  url: "https://google.com",
  method: "get"
}

interface StrictHttpStuff {
  url: string,
  method: XhrTypes.Sensitive,
  body?: any
}
declare function needStrictHttpStuff(httpStuff: StrictHttpStuff): Promise<{}>;

needStrictHttpStuff(httpStuff); // error, bad method

needStrictHttpStuff({
   url: httpStuff.url, 
   method: XhrTypes.toSensitive(httpStuff.method) 
  }); // okay

在上述代码中,有一个函数期望传入大写的值,但是如果你先使用XhrTypes.toSensitive()将大小写不敏感的值转换为大小写敏感的值,那么你可以安全地传入该函数。在这种情况下,编译器会验证"get"是否是可接受"GET"的变体。希望这对你有所帮助,祝你好运。

显然,如果我在这个问题上设置赏金,那么我的目的不是为了得到一个需要将每个可能性(甚至像pOsT这样的单词)放入字典中的暴力解决方案。为什么我不能使用i正则表达式标志呢? - Knu
1
有人可能会认为,如果语言维护者之一最近编写的当前被接受的答案说不可能,那么就是不可能的。我的答案是我能想到的唯一解决方法。你不能使用正则表达式,因为它不是字符串字面量类型系统的一部分。在 GitHub 上已经有一个建议来支持这个功能,但添加起来并不简单。如果你有兴趣为语言的开发做出贡献,也许你可以帮忙开发它! - jcalz
@Knu,你的具体用例是什么?也许使用类型守卫品牌类型会有所帮助?如果没有具体的用例,我不确定是否有更好的答案适合你。 - jcalz
这没有任何意义;这是一个非常基本的用例,我无法相信它还没有得到支持。我不知道他是维护者之一。我希望有人能想出一个聪明的解决方案。 - Knu

1

虽然不是所要求的类型,但如果可以使用枚举,则可以使用以下内容进行枚举字符串值的大小写不敏感匹配:

/**
 * Gets an enumeration given a case-insensitive key. For a numeric enum this uses
 * its members' names; for a string enum this searches the specific string values.
 * Logs a warning if the letter case was ignored to find a match, and logs an error
 * including the supported values if no match was found.
 */
static toEnumIgnoreCase<T>(target: T, caseInsentiveKey: string): T[keyof T] {
    const needle = caseInsentiveKey.toLowerCase();

    // If the enum Object does not have a key "0", then assume a string enum
    const key = Object.keys(target)
      .find(k => (target['0'] ? k : target[k]).toLowerCase() === needle);

    if (!key) {
        const expected = Object.keys(target)
          .map(k => target['0'] ? k : target[k])
          .filter(k => isNaN(Number.parseInt(k)))
          .join(', ');
        console.error(`Could not map '${caseInsentiveKey}' to values ${expected}`);
        return undefined;
    }

    const name = target['0'] ? key : target[key];
    if (name !== caseInsentiveKey) {
        console.warn(`Ignored case to map ${caseInsentiveKey} to value ${name}`);
    }

    return target[key];
}

当然,由于此循环遍历可能的值,它实际上只适用于处理配置文件之类的内容;所有的代码都应该使用枚举值。
一些测试:
import Spy = jasmine.Spy;
import {ConfigHelper} from './config-helper';

// Should match on One, one, ONE and all:
enum NumberEnum { One, Two, Three }

// Should match on Uno, uno, UNO and all, but NOT on One, one, ONE and all:
enum StringEnum { One = 'Uno', Two = 'Dos', Three = 'Tres' }

describe('toEnumIgnoreCase', () => {

    beforeEach(function () {
        spyOn(console, 'warn');
        spyOn(console, 'error');
    });

    it('should find exact match for numeric enum', () => {
        const result = ConfigHelper.toEnumIgnoreCase(NumberEnum, 'One');
        expect(result).toBe(NumberEnum.One);
        expect(console.warn).not.toHaveBeenCalled();
        expect(console.error).not.toHaveBeenCalled();
    });
    it('should find case-insensitive match for numeric enum', () => {
        const result = ConfigHelper.toEnumIgnoreCase(NumberEnum, 'two');
        expect(result).toBe(NumberEnum.Two);
        expect(console.warn).toHaveBeenCalled();
        expect((console.warn as Spy).calls.mostRecent().args[0])
          .toMatch(/value Two/);
        expect(console.error).not.toHaveBeenCalled();
    });
    it('should yield undefined for non-match for numeric enum', () => {
        const result = ConfigHelper.toEnumIgnoreCase(NumberEnum, 'none');
        expect(result).toBe(undefined);
        expect(console.warn).not.toHaveBeenCalled();
        expect(console.error).toHaveBeenCalled();
        expect((console.error as Spy).calls.mostRecent().args[0])
          .toMatch(/values One, Two, Three/);
    });

    it('should find exact match for string enum', () => {
        const result = ConfigHelper.toEnumIgnoreCase(StringEnum, 'Uno');
        expect(result).toBe(StringEnum.One);
        expect(console.warn).not.toHaveBeenCalled();
        expect(console.error).not.toHaveBeenCalled();
    });
    it('should find case-insensitive match for string enum', () => {
        const result = ConfigHelper.toEnumIgnoreCase(StringEnum, 'dos');
        expect(result).toBe(StringEnum.Two);
        expect(console.warn).toHaveBeenCalled();
        expect((console.warn as Spy).calls.mostRecent().args[0])
          .toMatch(/value Dos/);
        expect(console.error).not.toHaveBeenCalled();
    });
    it('should yield undefined for name rather than string value', () => {
        const result = ConfigHelper.toEnumIgnoreCase(StringEnum, 'One');
        expect(result).toBe(undefined);
        expect(console.warn).not.toHaveBeenCalled();
        expect(console.error).toHaveBeenCalled();
        expect((console.error as Spy).calls.mostRecent().args[0])
          .toMatch(/values Uno, Dos, Tres/);
    });
    it('should yield undefined for non-match for string enum', () => {
        const result = ConfigHelper.toEnumIgnoreCase(StringEnum, 'none');
        expect(result).toBe(undefined);
        expect(console.warn).not.toHaveBeenCalled();
        expect(console.error).toHaveBeenCalled();
        expect((console.error as Spy).calls.mostRecent().args[0])
          .toMatch(/values Uno, Dos, Tres/);
    });
});

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接