如何在TypeScript中使用泛型编写可重用的TSV解析器?

gojuced7  于 2023-01-10  发布在  TypeScript
关注(0)|答案(1)|浏览(169)

我试图实现一个函数parseTSV(),它解析TSV文件并返回一个对象数组,但我很难弄清楚如何键入该函数。
这是我目前掌握的情况:

export const parseTsv = <T>(tsvFilepath: string) => {
    const tsv = fs.readFileSync(tsvFilepath, 'utf8');
    const lines = tsv.split('\n');
    const result: T[] = [];
    const columns = lines[0].split('\t'); // How can I constrain columns to be a key of T?

    for (let i = 2; i < lines.length; i++) {
        const obj = {} as T;
        const currentline = lines[i].split("\t");

        for (let j = 0; j < columns.length; j++) {
            obj[columns[j]] = currentline[j]; /* ERROR: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'unknown'.
  No index signature with a parameter of type 'string' was found on type 'unknown'. */
        }

        result.push(obj);
    }

    return result;
}

我在编写函数时希望调用者定义<T>,它是从TSV文件解析的对象的接口(即TSV文件中的列)。

wyyhbhjk

wyyhbhjk1#

调用签名

declare const parseTsv: <T>(tsvFilepath: string) => T[]

无法安全地实现。它声称接受tsvFilepath字符串并生成由调用方在设计时提供的T类型的值数组。但当TS编译为JS时,静态类型系统被擦除,并且T只是设计时类型,而不是运行时值。运行的函数没有关于T的信息,所以如果它真的起作用了那也是巧合您将依赖于 * 开发人员 * 来验证返回的类型是否正确。
请考虑以下TypeScript代码:

interface Foo {
    bar: string;
    baz: string;
}
const foos: Foo[] = parseTsv<Foo>("somePath");
foos.map(x => x.bar.toUpperCase());

interface EvilFoo {
    bar: number;
    baz: string;
}
const evils: EvilFoo[] = parseTsv<EvilFoo>("somePath");
evils.map(x => x.bar.toFixed());

在运行时,几乎可以肯定

const foos = parseTsv("somePath");
foos.map(x => x.bar.toUpperCase());
const evils = parseTsv("somePath");
evils.map(x => x.bar.toFixed());

其中parseTsv("somePath")运行两次,具有相同的输入,而且我认为可以安全地假设它们将返回相同的输出。没有涉及到FooEvilFoo"somePath"处的文件表示 * Foo对象数组 * 和 * EvilFoo对象数组的几率相当低(在bar属性中同时包含stringnumber的对象是不可能的),因此很可能至少有一个map()方法将尝试取消引用undefined,并且您将得到一个运行时错误。
实际上,您的parseTsv()实现甚至不能生成一个具有非string属性的类型,而T是完全不受约束的。但是,即使我们将T约束为只有string属性,这个函数不知道它能得到什么键。给你的parseTsv()一个调用签名的唯一安全的方法是非泛型的:

declare const parseTsv: (tsvFilepath: string) => {[k: string]: string | undefined}[]

这意味着输出将是属性为stringundefined的一些对象的数组。
如果您希望在运行时保持相同的实现和相同的类型,那么我不会太担心实现中的类型安全;只需使用类型Assert或any类型来避免警告:

export const parseTsv = <T extends Record<keyof T, string>>(
    tsvFilepath: string) => {
    const tsv = fs.readFileSync(tsvFilepath, 'utf8');
    const lines = tsv.split('\n');
    const result: T[] = [];
    const columns = lines[0].split('\t');
    for (let i = 1; i < lines.length; i++) {
        const obj = {} as any; // just use any
        const currentline = lines[i].split("\t");
        for (let j = 0; j < columns.length; j++) {
            obj[columns[j]] = currentline[j];
        }
        result.push(obj);
    }
    return result;
}

如果你想要更安全的类型,你需要改变你的调用签名和实现。一个可能的方法是:

const parseTsv = <K extends string>(tsvFilepath: string, ...keys: K[]) => {
    const tsv = fs.readFileSync(tsvFilepath, 'utf8');
    const lines = tsv.split('\n');
    const result: { [P in K]: string }[] = [];
    if (!lines.length) return result;
    const columns = lines[0].split('\t') as K[];
    for (const key of keys) {
        if (!columns.includes(key)) throw new Error(
            "TSV file is missing expected header named \"" +
            key + "\" in line 1"
        );
    }
    for (let i = 1; i < lines.length; i++) {
        const obj = {} as { [P in K]: string };
        const currentline = lines[i].split("\t");
        for (let j = 0; j < columns.length; j++) {
            const key = columns[j];
            const val = currentline[j];
            if (typeof val !== "string") throw new Error(
                "TSV file is missing expected value for \"" +
                key + "\" in line " + (i + 1)
            );
            obj[key] = currentline[j];
        }
        result.push(obj);
    }
    return result;
}

这里你传递的是你希望对象上存在的keys的列表。这个函数只在这些键的类型K中是泛型的。输出类型是{ [P in K]: string }[],意味着一个元素数组,其键是K,值是string s。
我在实现中使用了一些类型Assert来防止错误(例如as K[]as {[P in K]: string}),这意味着我已经自己承担了保证类型安全的责任。我已经尝试过这样做,如果键不匹配或者如果一行缺少一个条目,则让实现抛出错误。唯一可能的检查方法是,在运行时,类型K[]keys数组存在(而K本身仅在设计时存在)。
无论如何,让我们用一个虚拟文件系统来测试它:

const fs = {
    readFileSync(path: string, encoding: string) {
        switch (path) {
            case "badFile":
                return "bar\tbaz\nabc\nghi\tjkl"
            default:
                return "bar\tbaz\nabc\tdef\nghi\tjkl";
        }
    }
}

首先是快乐案例:

interface Foo {
    bar: string;
    baz: string;
}
const objects: Foo[] = parseTsv("somePath", "bar", "baz");
console.log(objects);
//  [{ "bar": "abc", "baz": "def" }, { "bar": "ghi", "baz": "jkl" }] 
objects.map(x => x.bar.toUpperCase())

这是可行的,因为TSV中有"bar""baz"密钥。您可以确信,假设objectsFoo[]类型,您执行的任何操作都将有效。

interface Other {
    other: string;
    key: string;
}
try {
    const others = parseTsv("somePath", "other", "key"); // RUNTIME ERROR!
    // const others: {other: string; key: string; }[]
    others.map(x => x.other.toUpperCase())
} catch (e) {
    console.log(e); // TSV file is missing expected header named "other" in line 1
}

try {
    const oops: Foo[] = parseTsv("badFile", "bar", "baz");
    oops.map(x => x.bar.toUpperCase())
} catch (e) {
    console.log(e); // TSV file is missing expected value for "baz" in line 2
}

这些操作失败的原因是"somePath"处的文件没有"other""key"标头,并且"badFile"处的文件是坏的,这两个都是在运行时捕获的。others.map()oops.map()行永远不会到达。假设others的类型是Other[]oops的类型是Foo[]是没有问题的,因为代码运行的唯一方法是满足这些约束,所以它是合理的类型安全的。
Playground代码链接

相关问题