是否可以限制TypeScript对象仅包含由其类定义的属性?

k97glaaz  于 2023-02-17  发布在  TypeScript
关注(0)|答案(9)|浏览(205)

下面是我的代码

async getAll(): Promise<GetAllUserData[]> {
    return await dbQuery(); // dbQuery returns User[]
}

class User {
    id: number;
    name: string;
}

class GetAllUserData{
    id: number;
}

getAll函数返回User[],数组的每个元素都具有name属性,即使其返回类型为GetAllUserData[]
我想知道是否可以在TypeScript中"开箱即用"地将对象限制为仅由其类型指定的属性。

t98cgbkg

t98cgbkg1#

我找到了一种方法,使用自TypeScript第3版以来可用的内置类型来确保传递给函数的对象不包含指定(对象)类型之外的任何属性。

// First, define a type that, when passed a union of keys, creates an object which 
// cannot have those properties. I couldn't find a way to use this type directly,
// but it can be used with the below type.
type Impossible<K extends keyof any> = {
  [P in K]: never;
};

// The secret sauce! Provide it the type that contains only the properties you want,
// and then a type that extends that type, based on what the caller provided
// using generics.
type NoExtraProperties<T, U extends T = T> = U & Impossible<Exclude<keyof U, keyof T>>;

// Now let's try it out!

// A simple type to work with
interface Animal {
  name: string;
  noise: string;
}

// This works, but I agree the type is pretty gross. But it might make it easier
// to see how this works.
//
// Whatever is passed to the function has to at least satisfy the Animal contract
// (the <T extends Animal> part), but then we intersect whatever type that is
// with an Impossible type which has only the keys on it that don't exist on Animal.
// The result is that the keys that don't exist on Animal have a type of `never`,
// so if they exist, they get flagged as an error!
function thisWorks<T extends Animal>(animal: T & Impossible<Exclude<keyof T, keyof Animal>>): void {
  console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
}

// This is the best I could reduce it to, using the NoExtraProperties<> type above.
// Functions which use this technique will need to all follow this formula.
function thisIsAsGoodAsICanGetIt<T extends Animal>(animal: NoExtraProperties<Animal, T>): void {
  console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
}

// It works for variables defined as the type
const okay: NoExtraProperties<Animal> = {
  name: 'Dog',
  noise: 'bark',
};

const wrong1: NoExtraProperties<Animal> = {
  name: 'Cat',
  noise: 'meow'
  betterThanDogs: false, // look, an error!
};

// What happens if we try to bypass the "Excess Properties Check" done on object literals
// by assigning it to a variable with no explicit type?
const wrong2 = {
  name: 'Rat',
  noise: 'squeak',
  idealScenarios: ['labs', 'storehouses'],
  invalid: true,
};

thisWorks(okay);
thisWorks(wrong1); // doesn't flag it as an error here, but does flag it above
thisWorks(wrong2); // yay, an error!

thisIsAsGoodAsICanGetIt(okay);
thisIsAsGoodAsICanGetIt(wrong1); // no error, but error above, so okay
thisIsAsGoodAsICanGetIt(wrong2); // yay, an error!
9wbgstp7

9wbgstp72#

Typescript无法限制额外属性

不幸的是这在Typescript中是不可能的,并且在某种程度上与TS类型检查的形状特性相矛盾。
这个线程中依赖于通用NoExtraProperties的答案非常优雅,但不幸的是,它们不可靠,并且可能导致难以检测的bug。
我将用GregL的答案来演示。

// From GregL's answer

type Impossible<K extends keyof any> = {
    [P in K]: never;
 };

 type NoExtraProperties<T, U extends T = T> = U & Impossible<Exclude<keyof U, keyof T>>;

 interface Animal {
    name: string;
    noise: string;
 }

 function thisWorks<T extends Animal>(animal: T & Impossible<Exclude<keyof T, keyof Animal>>): void {
    console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
 }

 function thisIsAsGoodAsICanGetIt<T extends Animal>(animal: NoExtraProperties<Animal, T>): void {
    console.log(`The noise that ${animal.name.toLowerCase()}s make is ${animal.noise}.`);
 }

 const wrong2 = {
    name: 'Rat',
    noise: 'squeak',
    idealScenarios: ['labs', 'storehouses'],
    invalid: true,
 };

 thisWorks(wrong2); // yay, an error!

 thisIsAsGoodAsICanGetIt(wrong2); // yay, an error!

如果在传递一个对象到thisWorks/thisIsAsGoodAsICanGet的时候TS识别出这个对象有额外的属性,那么这个方法就可以工作,但是在TS中,如果它不是一个对象字面量,那么一个值总是可以有额外的属性:

const fun = (animal:Animal) =>{
    thisWorks(animal) // No Error
    thisIsAsGoodAsICanGetIt(animal) // No Error
}

fun(wrong2) // No Error

因此,在thisWorks/thisIsAsGoodAsICanGetIt中,你不能相信animal param没有额外的属性。
溶液
只需使用拾取(Lodash,Ramda,下划线)。

interface Narrow {
   a: "alpha"
}

interface Wide extends Narrow{
   b: "beta" 
}

const fun = (obj: Narrow) => {
   const narrowKeys = ["a"]
   const narrow = pick(obj, narrowKeys) 
   // Even if obj has extra properties, we know for sure that narrow doesn't
   ...
}
uz75evzq

uz75evzq3#

Typescript uses structural typing instead of nominal typing to determine type equality.这意味着类型定义实际上只是该类型对象的“形状”,也意味着共享另一个类型“形状”子集的任何类型都隐含地是该类型的子类。
在您的示例中,因为User具有GetAllUserData的所有属性,所以User隐式地是GetAllUserData的子类型。
要解决这个问题,你可以添加一个伪属性来使你的两个类彼此不同。这种类型的属性叫做鉴别器。(搜索鉴别联合here)。
您的代码可能如下所示。discriminator属性的名称并不重要。这样做将产生您想要的类型检查错误。

async function getAll(): Promise<GetAllUserData[]> {
  return await dbQuery(); // dbQuery returns User[]
}

class User {
  discriminator: 'User';
  id: number;
  name: string;
}

class GetAllUserData {
  discriminator: 'GetAllUserData';
  id: number;
}
mccptt67

mccptt674#

我不认为这是可能的代码结构。Typescript * 确实 * 有多余的属性检查,这听起来像你所追求的,但他们只工作的对象字面量。从这些文档:
对象文本会得到特殊处理,在将其赋给其他变量或将其作为参数传递时,会进行额外的属性检查。
但是返回的变量不会接受这种检查。

function returnUserData(): GetAllUserData {
    return {id: 1, name: "John Doe"};
}

会产生错误“Object literal可能只指定已知属性”,代码:

function returnUserData(): GetAllUserData {
    const user = {id: 1, name: "John Doe"};
    return user;
}

不会产生任何错误,因为它返回的是变量而不是对象文本本身。
因此,对于您的情况,由于getAll不返回文本,typescript不会执行多余的属性检查。

  • 最后说明:有一个issue for "Exact Types",如果实现了它,它将允许您在这里进行所需的检查。*
41zrol4v

41zrol4v5#

GregLanswer之后,我想添加对数组的支持,并确保如果您有一个数组,则数组中的所有对象都没有额外的props:

type Impossible<K extends keyof any> = {
  [P in K]: never;
};

export type NoExtraProperties<T, U extends T = T> = U extends Array<infer V>
  ? NoExtraProperties<V>[]
  : U & Impossible<Exclude<keyof U, keyof T>>;

注意:只有当你有TS 3. 7(包含)或更高版本时,类型递归才是可能的。

chhqkbe1

chhqkbe16#

带鉴别器的公认答案是正确的。TypeScript使用结构类型而不是名义类型。这意味着转发器将检查结构是否匹配。由于两个类(可以是接口或类型)都有number类型的id,因此它匹配,因此可以互换(这是正确的一面,因为User有更多的属性。
虽然这可能已经足够好了,但问题是在运行时,从方法getAll返回的数据将包含name属性。返回更多可能不是问题,但如果您将信息发送回其他地方,则可能是问题。
如果你想把数据限制在类中定义的范围内(接口或类型),你必须手工构建或扩展一个新的对象。下面是它如何查找你的例子:

function dbQuery(): User[] {
    return [];
}
function getAll(): GetAllUserData[] {
    const users: User[] = dbQuery();
    const usersIDs: GetAllUserData[] = users.map(({id}) => ({id}));
    return usersIDs;
}

class User {
    id: number;
    name: string;
}

class GetAllUserData {
    id: number;
}

如果不使用修剪字段的运行时方法,您可以向TypeScript指示这两个类不同,具有私有字段。当返回类型设置为GetAllUserData时,下面的代码不允许您返回User

class User {

    id: number;
    name: string;
}

class GetAllUserData {
    private _unique: void;
    id: number;
}
function getAll(): GetAllUserData[] {
    return dbQuery(); // Doesn't compile here!
}
h7appiyu

h7appiyu7#

我发现了另一种解决方法:

function exactMatch<A extends C, B extends A, C = B>() { }

const a =  { a: "", b: "", c: "" } 
const b =  { a: "", b: "", c: "", e: "" } 
exactMatch<typeof a, typeof b>() //invalid

const c =  { e: "", } 
exactMatch<typeof a, typeof c>() //invalid

const d =  { a: "", b: "", c: "" } 
exactMatch<typeof a, typeof d>() //valid 

const e = {...a,...c}
exactMatch<typeof b, typeof e>() //valid

const f = {...a,...d}
exactMatch<typeof b, typeof f>() //invalid

查看原始帖子
Playground链接

ukdjmx9f

ukdjmx9f8#

作为一种选择,你可以去与黑客:

const dbQuery = () => [ { name: '', id: 1}];

async function getAll(): Promise<GetAllUserData[]> {
    return await dbQuery(); // dbQuery returns User[]
}

type Exact<T> = {[k: string | number | symbol]: never} & T

type User = {
    id: number;
    name: string;
}

type GetAllUserData = Exact<{
    id: number;
}>

此操作产生的错误:

Type '{ name: string; id: number; }[]' is not assignable to type '({ [k: string]: never; [k: number]: never; [k: symbol]: never; } & { id: number; })[]'.
  Type '{ name: string; id: number; }' is not assignable to type '{ [k: string]: never; [k: number]: never; [k: symbol]: never; } & { id: number; }'.
    Type '{ name: string; id: number; }' is not assignable to type '{ [k: string]: never; [k: number]: never; [k: symbol]: never; }'.
      Property 'name' is incompatible with index signature.
        Type 'string' is not assignable to type 'never'.
4xy9mtcn

4xy9mtcn9#

当使用类型而不是接口时,属性受到限制。至少在IDE中是这样(没有运行时检查)。
示例

type Point = {
  x: number;
  y: number;
}

const somePoint: Point = {
  x: 10,
  y: 22,
  z: 32
}

它抛出:
Type '{ x: number; y: number; z: number; }' is not assignable to type 'Point'. Object literal may only specify known properties, and 'z' does not exist in type 'Point'.
我认为与接口相比,类型对于定义封闭的数据结构是很好的。当数据与形状不完全匹配时,让IDE(实际上是编译器)大喊大叫已经是开发时一个很好的类型守护者

相关问题