TypeScript为什么允许将子类型分配给对象属性?

huangapple go评论46阅读模式
英文:

Why does TypeScript allow assignment of a subtype to an object property?

问题

Consider this example:

type Base = {
    a: number,
    b: number
}

type SubTest = Base & {
    c: number
}

function run(arg: { prop: Base }) {
    arg.prop = {
        a: 1,
        b: 2
    };
}

const prop: SubTest = {
    a: 1,
    b: 2,
    c: 3
};

const originalObject = { prop };

run(originalObject);

console.log(originalObject.prop.c.toFixed(2));

TypeScript看起来完全可以处理这个,但生成的JS会产生明显的错误:

originalObject.prop.c is undefined

.tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "strict": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "noImplicitThis": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "baseUrl": ".",
    "paths": {
      "@/": ["./resources/js/"]
    },
    "types": [
        "vite/client"
    ],
    "skipLibCheck": true
  },
  ...
}
英文:

Consider this example:

type Base = {
    a: number,
    b: number
}

type SubTest = Base & {
    c: number
}

function run(arg: { prop: Base }) {
    arg.prop = {
        a: 1,
        b: 2
    };
}

const prop: SubTest = {
    a: 1,
    b: 2,
    c: 3
};

const originalObject = { prop };

run(originalObject);

console.log(originalObject.prop.c.toFixed(2));

TypeScript seems perfectly okay with this, but the resulting JS produces the obvious error:

> originalObject.prop.c is undefined

.tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "strict": true,
    "sourceMap": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "noImplicitThis": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./resources/js/*"]
    },
    "types": [
        "vite/client"
    ],
    "skipLibCheck": true
  },
  ...
}

答案1

得分: 2

Sure, here's the translated code:

由于SubTest扩展了Base,这意味着SubTestBase的子类型,因此您可以将任何SubTest作为Base传递。您可以将其解释为面向对象编程的工作方式,您可以将子类传递给期望父类的方法。问题在于,对于Typescript而言,一切都没问题,因为您确切地期望一个Base,如前所述,SubTest满足这个条件。

之所以可能出现这种赋值情况,是因为Typescript的类型系统被称为结构类型系统,它仅检查类型的所需形状是否已达到,不检查附加字段。简而言之,与Java相比,Typescript仅检查所需字段的存在,而Java则检查确切的标识。

基本上,您可以定义另一种与Base无关但具有其所有字段的类型,run不会对其提出异议:

type NotBase = {
   a: number;
   b: number;
}

const notBase: NotBase = {a: 1, b: 2};

run({ prop: notBase }); // 无错误

如果我们参考官方文档,我们可以看到完全相同的情况发生:

interface Point {
  x: number;
  y: number;
}
 
function logPoint(p: Point) {
  console.log(`${p.x}, ${p.y}`);
}

const point = { x: 12, y: 26 };
logPoint(point); // 无错误

point变量从未声明为Point类型。但是,TypeScript将point的形状与类型检查中的Point的形状进行比较。它们具有相同的形状,因此代码通过。形状匹配只需要对象字段的子集匹配。

相同的文档:

const point3 = { x: 12, y: 26, z: 89 };
logPoint(point3); // 无错误
 
const rect = { x: 33, y: 3, width: 30, height: 80 };
logPoint(rect); // 无错误

为了使函数更安全,您应该添加一个扩展Base的通用参数:

function run<T extends Base>(arg: { prop: T }) {}

现在,在这个函数中,如果您尝试对arg.prop进行赋值,您将会得到一个错误:

function run<T extends Base>(arg: { prop: T }) {
  arg.prop = { // 这里会出错
    a: 1,
    b: 2,
  };
}

类型' { a: number; b: number; } '不能分配给类型'T'。
'{ a: number; b: number; } '可分配给类型'T'的约束,但'T'可以用不同的约束'Base'的子类型实例化。

这个错误意味着,由于您期望的是扩展Base的内容,因此不能安全地假设它就是完全的Base

要修复此错误,您可以这样做:

function run<T extends Base>(arg: { prop: T }) {
  arg.prop = {
    ...arg.prop,
    a: 1,
    b: 2,
  };
}

这样,您将添加T的所有必要字段,并更改来自Base的已知字段(ab)。

作为传播的替代方案,您可以单独分配ab

  arg.prop.a = 1;
  arg.prop.b = 1;

播放区

或者,您可以使用泛型限制run再次接受纯粹的Base

function run<T extends Base>(arg: { prop: OnlyBase<T> }) {}

现在,让我们定义OnlyBase

type OnlyBase<T extends Base> = {} extends Omit<T, keyof Base> ? T : never;

这个类型将检查在删除所有Base的键后,T是否会成为空对象。如果是的话,我们可以假设T是基类,否则返回never以引发错误。尽管对于编译器而言,这个条件不会改变类型推断,您仍然需要传播arg.prop以消除先前描述的错误。

测试:

const prop: Base = {
  a: 1,
  b: 2,
};

const originalObject = { prop };

run(originalObject);

const prop2: SubTest = {
  a: 1,
  b: 2,
  c: 3,
};

const originalObject2 = { prop2 };

run(originalObject2); // 预期错误

[播放区](https://www.typescriptlang

英文:

Since, SubTest extends Base, which means that SubTest is a sub-type of Base, you can pass any SubTest as Base. You can interpret that to how OOP works, you can pass a child class to a method that expects the parent class. The issue here is that for Typescript everything is fine since you expect exactly a Base and as mentioned before, SubTest obeys this condition.

The reason why such an assignment is possible is due to Typescript's type system, which is known as structural type system, that only checks if the needed shape of the type is reached and doesn't check for the additional fields. In simple words compared to Java, Typescript checks only for the existence of the required fields, while Java checks for the exact identity.

Basically, you could define another type that is independent of Base, but has its all fields, and run wouldn't complain about it:

type NotBase = {
   a: number;
   b: number;
}

const notBase: NotBase = {a: 1, b: 2};

run({ prop: notBase }); // no error

If we refer to official docs we can see exactly the same situation happening:

interface Point {
  x: number;
  y: number;
}
 
function logPoint(p: Point) {
  console.log(`${p.x}, ${p.y}`);
}

const point = { x: 12, y: 26 };
logPoint(point); // no error

> The point variable is never declared to be a Point type. However, TypeScript compares the shape of point to the shape of Point in the type-check. They have the same shape, so the code passes. The shape-matching only requires a subset of the object’s fields to match.

Same docs:

const point3 = { x: 12, y: 26, z: 89 };
logPoint(point3); // no error
 
const rect = { x: 33, y: 3, width: 30, height: 80 };
logPoint(rect); // no error

To make the function safer, you should add a generic argument that extends Base:

function run&lt;T extends Base&gt;(arg: { prop: T }) {}

Now, in this function if you try to do the assignment to arg.prop, you will get an error:

function run&lt;T extends Base&gt;(arg: { prop: T }) {
  arg.prop = { // error here
    a: 1,
    b: 2,
  };
}

> Type '{ a: number; b: number; }' is not assignable to type 'T'.
'{ a: number; b: number; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Base'.

This error means that since you expect something that extends Base it is not safe to assume that it is exactly Base.

To fix this error you can do the following:

function run&lt;T extends Base&gt;(arg: { prop: T }) {
  arg.prop = {
    ...arg.prop,
    a: 1,
    b: 2,
  };
}

This way you add all necessary fields of T and change the known ones from the Base (a and b).

As an alternative to spread you could assign a and b separately:

  arg.prop.a = 1;
  arg.prop.b = 1;

playground

Alternatively, you can restrict the run to accept pure Base again by using the generics:

function run&lt;T extends Base&gt;(arg: { prop: OnlyBase&lt;T&gt; }) {}

Now, let's define OnlyBase:

type OnlyBase&lt;T extends Base&gt; = {} extends Omit&lt;T, keyof Base&gt; ? T : never;

This type will check if the T will be an empty object after removing all of the keys of Base. If that's true that we can assume that T is base, otherwise return never to raise an error. Though, for the compiler this conditional won't change the type inference and you will still have to spread the arg.prop to remove the error described previously.

Testing:

const prop: Base = {
  a: 1,
  b: 2,
};

const originalObject = { prop };

run(originalObject);

const prop2: SubTest = {
  a: 1,
  b: 2,
  c: 3,
};

const originalObject2 = { prop2 };

run(originalObject2); // expected error

playground

答案2

得分: 0

TypeScript 允许将子类型分配给对象属性,因为它支持结构类型,并允许对象形状的灵活性。这种行为是有意的,并且是 TypeScript 设计哲学的一部分。

在你的示例中,run 函数接受一个类型为 { prop: Base } 的参数,这意味着它期望一个具有名为 prop 且类型为 Base 的属性的对象。当你将 originalObject 传递给 run 函数时,类型检查器会看到 originalObject.prop 与期望的类型 Base 匹配,因为 SubTestBase 的子类型。这是因为 SubTest 通过添加额外的属性 c 扩展了 Base。

在编译时,TypeScript 允许这种分配,因为它验证提供的对象至少具有 Base 的所需属性。然而,在运行时,当你访问 originalObject.prop.c 时,你会遇到一个错误,因为实际对象没有定义属性 c。

这种行为是静态类型检查和运行时行为之间的一种权衡。TypeScript 的设计目标是在开发过程的早期捕获潜在的类型错误,并通过提供类型安全性和代码补全来提供更好的开发体验。然而,它无法保证运行时行为,因为 JavaScript 是一种动态类型的语言。

为了避免运行时错误,你可以在将其传递给 run 函数时将 originalObject.prop 的类型细化为 SubTest,像这样:

run({ prop: prop });

通过提供具体的类型,类型检查器将在编译时捕获任何不一致之处,并且运行时行为将与期望的类型匹配。

英文:

TypeScript allows assignment of a subtype to an object property because it supports structural typing and allows for flexibility in object shapes. This behavior is intentional and part of TypeScript's design philosophy.

In your example, the run function accepts an argument of type { prop: Base }, which means it expects an object with a property named prop of type Base. When you pass originalObject to the run function, the type checker sees that originalObject.prop matches the expected type Base since SubTest is a subtype of Base. This is because SubTest extends Base by adding an additional property c.

At compile-time, TypeScript allows this assignment because it verifies that the provided object has at least the required properties of Base. However, during runtime, when you access originalObject.prop.c, you will encounter an error because the actual object does not have the c property defined.

This behavior is a trade-off between static type checking and runtime behavior. TypeScript's design aims to catch potential type errors early in the development process and provide a better development experience by providing type safety and code completion. However, it cannot guarantee runtime behavior since JavaScript is a dynamically-typed language.

To avoid runtime errors, you can refine the type of originalObject.prop to SubTest when passing it to the run function, like this:

run({ prop: prop });

By providing the specific type, the type checker will catch any inconsistencies at compile-time, and the runtime behavior will match the expected type.

huangapple
  • 本文由 发表于 2023年6月15日 05:17:44
  • 转载请务必保留本文链接:https://go.coder-hub.com/76477605.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定