TypeScript 类型忽略大小写

TypeScript type ignore case

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

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

遗憾的是,这是区分大小写的...有什么方法可以定义它不区分大小写吗?

谢谢

所以有一个答案post:不,这是不可能的。

2018 年 5 月 15 日更新:仍然不可能。最接近的regex-validated字符串类型,不是最近一次在语言设计会议上提出的well-received

正如@RyanCavanaugh 所说,TypeScript 没有不区分大小写的字符串文字。 [编辑:我记得有一个现有的 suggestion 用于 TypeScript 以支持正则表达式验证的字符串文字,这可能允许这样做,但它目前不是该语言的一部分。]

我能想到的唯一解决方法是枚举这些文字最可能的变体(例如,全部小写,init cap)并创建一个函数,如果需要可以在它们之间进行转换:

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) 需要它做什么或你打算如何使用它,但我想你想在 sensitive/insensitive 方法之间转换,或者检查是否两个不区分大小写的方法是匹配的。显然,您可以在运行时通过转换为大写或进行不区分大小写的比较来完成这些操作,但在编译时,上述类型可能会有用。

这是一个使用它的例子:

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" 在这种情况下的变体。

好的,希望对您有所帮助。祝你好运。

虽然不是要求的类型,但如果枚举没问题,则以下可用于枚举字符串值的不区分大小写的匹配:

/**
 * 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];
}

当然,由于它遍历了可能的值,它实际上只是为了处理配置文件之类的东西;所有代码都应该真正使用 enum 值。

一些测试:

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/);
    });
});

打字稿 4.1+ 的新答案

欢迎回来!现在 TypeScript 4.1 已经引入 template literal types and the Uppercase/Lowercase intrinsic string mapping types,我们现在可以在不需要正则表达式类型的情况下回答这个问题。


主要有两种方法。 “蛮力”方法大量使用 recursive conditional types 和联合将你的 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

解决这个问题的一种方法是不再使用特定的具体联合,而是改用 generic type representation. If T is the type of a string value passed to acceptAnyCaseXhrType(), then all we want to do is make sure that Uppercase<T> is assignable to xhrType. This is more of a constraint 而不是类型(尽管我们不能直接使用通用约束来表达这一点):

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 link to code