typescript 基于配置对象智能地强制参数类型

8ftvxx2r  于 2023-05-19  发布在  TypeScript
关注(0)|答案(1)|浏览(227)

我不知道该怎么称呼这个,所以请在我描述它的时候与我裸露。
我有三个配置对象:

const configA = {
    type: 'A' as const,
    getPath: (query: { foo: string }) => `/${query.foo}`
}

const configB = {
    type: 'B' as const,
    getPath: (query: {}) => `/path/b`
}

const configC = {
    type: 'C' as const,
    getPath: (query: {bar: number}) => `/c/${query.bar}`
}

我把这些合并成一个主记录,如下所示:

const CONFIGS = {
    [configA.type]: configA,
    [configB.type]: configB,
    [configC.type]: configC
}

我现在想写一个函数,它的参数遵循上面的配置。从而提供类型安全。上面的如果我这样做:

add({
    type: 'C',
    query: { foo: true }
})

Typescript应该警告我,如果typeC,则这里的query对象无效。但它没有警告我。
我试过这个:

function add(inputs: {
    type: (typeof CONFIGS)[keyof typeof CONFIGS]['type'],
    query: Parameters<(typeof CONFIGS)[keyof typeof CONFIGS]['getPath']>[0]
}) {
 const config = CONFIGS[inputs.type];
 if (!config) {
    throw new Error('Config not found');
 }

 const url = 'mysite.blah' + config.getPath(inputs.query); // inputs.query is getting Typescript warning

 console.log('url:', url);
}

然而,它不工作,它有一个额外的问题,在我做config.getPath(inputs.query)的那一行,inputs.query抛出了这个Typescript错误:
类型“{}”的参数|{ foo:string;}|{ bar:number;}'不能赋值给类型为'{ foo:string; } & { bar:number;}'。
以下是我的完整代码:TypeScript Playground
这种技术叫什么?有可能解决这个问题吗?谢谢。

yhuiod9q

yhuiod9q1#

add的调用签名不是类型安全的,因为inputs参数的typequery属性彼此独立。两者都是联合类型,每个都可以是(三个)可能值中的任何一个。您可以通过将inputs的类型设置为单个联合来解决此问题,以便typequery相互关联:

type Inputs = {
    [P in keyof typeof CONFIGS]: {
        type: P,
        query: Parameters<typeof CONFIGS[P]["getPath"]>[0]
    }
}[keyof typeof CONFIGS]

/* type Inputs = 
  { type: "A"; query: { foo: string; }; } | 
  { type: "B"; query: {}; } | 
  { type: "C"; query: { bar: number; }; } */

declare function add(inputs: Inputs): void;

add({ type: 'C', query: { foo: true } }); // error!
add({ type: 'C', query: { bar: 123 } }); // okay

这从呼叫方的Angular 解决了您的问题。不幸的是,实现基本上有相同的错误:

function add(inputs: Inputs) {
    const config = CONFIGS[inputs.type];
    if (!config) {
        throw new Error('Config not found');
    }
    const url = 'mysite.blah' + config.getPath(inputs.query); // error!
    console.log('url:', url);
};

这是因为TypeScript缺乏对microsoft/TypeScript#30581中描述的“相关联合”的直接支持。当编译器类型检查单个代码块(如add()的主体)时,它只执行一次。仅仅从身体内部的表达式的类型来看,没有足够的信息来验证你正在做的事情是安全的。是的,inputs必须是正确的类型,但是当你像CONFIGS[inputs.type].getPath(inputs.query)一样尝试使用它两次时,编译器无法跟踪线程。如果它可以分析该代码 * 三次 *,每次联合收缩一次,它就会工作。但它只能这样做一次。
处理这个问题的推荐方法在microsoft/TypeScript#47109中有描述,它涉及到重构,这样操作就不是联合,而是用一个简单的键-值类型来表示,然后将genericindexes转换为该类型,再转换为该类型上的mapped types。我们的目标是使CONFIGS[input.type].getPath被视为一个函数(而不是联合),其参数与input.query的参数是相同的泛型类型。
重构可能看起来像这样:
首先让我们将CONFIGS重新命名,因为我们需要改变它的类型:

const _CONFIGS = {
    [configA.type]: configA,
    [configB.type]: configB,
    [configC.type]: configC
}

然后我们将使用它来构建简单的键值类型:

type QueryMap = { [K in keyof typeof _CONFIGS]: 
  Parameters<typeof _CONFIGS[K]["getPath"]>[0] 
}

/* type QueryMap = {
    A: { foo: string; };
    B: {};
    C: { bar: number;};
} */

我们将使用QueryMap类型作为这里涉及的关系的基础。您可以将其作为手动编写的类型并根据它定义CONFIGS,但这更容易扩展,因为如果您添加成员,它将自动更新。
现在我们将CONFIGS的类型重写为QueryMap上的Map类型:

type Configs = { [K in keyof QueryMap]: { type: K, getPath: (query: QueryMap[K]) => string } };

假设CONFIGS是这样的类型:

const CONFIGS: Configs = _CONFIGS; // okay

赋值成功,因此编译器能够看到typeof _CONFIGSConfigs之间的等价性。现在我们可以将add()重写为K extends keyof Configs中的泛型函数:

function add<K extends keyof Configs>(inputs: { type: K, query: QueryMap[K] }) {
    const config = CONFIGS[inputs.type];
    if (!config) {
        throw new Error('Config not found');
    }

    const url = 'mysite.blah' + config.getPath(inputs.query); // okay

    console.log('url:', url);
}

现在这个方法有效了。这很微妙,因为如果在函数的第一行中将CONFIGS替换为_CONFIGS,错误将再次出现。是的,它们的类型是等价的,但编译器并不以相同的方式对待它们。Configs类型表示为QueryMap上的Map类型,inputs也具有依赖于QueryMap的类型,并且这种对应关系可以用于对函数进行类型检查。_CONFIGS的类型不是相对于QueryMap来表示的,所以编译器看不到它和inputs之间的泛型连接。
对了,呼叫者仍然可以获得您想要的行为:

add({ type: 'C', query: { foo: true } }); // error!
add({ type: 'C', query: { bar: 123 } }); // okay

给你对于add()实现来说,这种重构是最接近编译器验证的类型安全的。我不确定这是否值得如果你不关心编译器在实现中检查你的代码,你可以只使用类型Assert来沉默它:

function add(inputs: Inputs) {
    const config = CONFIGS[inputs.type];
    if (!config) {
        throw new Error('Config not found');
    }
    const url = 'mysite.blah' + config.getPath(inputs.query as any); // okay
    console.log('url:', url);
};

这就容易多了这一切都取决于您的使用案例。
Playground链接到代码

相关问题