为什么打字稿类是表示数据的糟糕选择
#typescript #node #类

示例模型

出于本文的目的,我们将指代用户的相对简单的数据块:

{
  "firstName": "Joe",
  "lastName": "Schmoe",
  "age": 35,
  "address": {
    "streetAddress": "123 Some St",
    "city": "Nowhere",
    "state": "NY",
    "postalCode": "12345"
  },
  "contact": [
    {
      "type": "phone",
      "value": "212-555-1234"
    },
    {
      "type": "email",
      "value": "joe.schmoe@example.com"
    }
  ]
}

在介绍有关课程问题的细节之前,让我们快速检查Typescript代表数据的能力。

JSON格式

JSON在2000年代中期超过XML,作为序列化数据的首选格式。据推测,你们大多数人都熟悉该格式,但是值得一提的是考虑它的简单程度。整个Internet上的所有内容中的绝大多数仅包括三种数据类型和2种集合类型(!!!):

  • string-字符的有序顺序
  • number-数字值(所有浮点)
  • boolean-对还是错误
  • array-并订购了任何类型的列表
  • object-键/值存储在其中键是字符串,值是任何类型

此外,还有null。它的特定语义不是定义的,但总的来说,它是代表在某些情况下没有价值的已知属性的占位符。

请注意,JSON代表JavaScript对象符号。您可以将任何有效的JSON直接粘贴到JS或TS文件中的分配中,这将是有效的!这确实很重要,因为坦率地说,大多数其他语言在代表这种格式的数据方面都很糟糕。当使用JS/TS时,可能会倾向于找到其他语言/框架熟悉的模式,但我认为这是一个错误。让我们探索!

数据结构与数据内容一样重要

JSON由于可读性而引人注目。根本没有任何心理开销,您可以查看以上并了解用户的所有内容。它全部都在一个地方,其嵌套层是一种组织策略。

现在,一个简单的练习。一目了然,这是什么图像?

sliced up image

你能告诉吗?请参阅下面的答案:

目的是说明类如何安排结构化数据。由于类是平坦的,因此嵌套的每一层都需要一个新的类。因此,我们将数据切成水平切片,然后在我们的代码中表示。此外,我们必须首先定义最低部分,然后返回真正的物体。

上面的类结构可能看起来像:

class Address {
  streetAddress: string;
  city: string;
  state: string;
  postalCode: string;
}

class Contact {
  type: string;
  value: string;
}

class User {
  firstName: string;
  lastName: string;
  age: number;
  address: Address;
  contact: Contact[];
}

该结构的所有组织都被剥夺了。我们必须将所有要素促进一流的实体。我们想代表User,但是AddressContact现在是平等的。甚至没有立即明显哪个是“真实”实体。您必须阅读所有内容,从而在心理上跟踪谁依赖谁。大多数人可以在短期记忆中保持5至9件事。对于这种简单的结构,它是可行的,但是它离认知“杂耍”的极限不远。最终,您将习惯结构,通过死记硬背的记忆,它们将变得熟悉,但是您不能简单地阅读并理解形状。不应该只是为了弄清有效载荷的外观!

是一种心理练习。

扮演魔鬼的拥护者,如果这是可取的,它将像这个虚构的结构一样序列化:

{
  "address.streetAddress": "123 Some St",
  "address.city": "Nowhere",
  "address.state": "NY",
  "address.postalCode": "12345"
  "contact[0].value": "212-555-1234"
  "contact[0].type": "phone",
  "contact[1].value": "joe.schmoe@example.com"
  "contact[1].type": "email",
  "firstName": "Joe",
  "lastName": "Schmoe",
  "age": 35,
}

现在,让我们考虑如何在惯用的打字稿中表示这一点:

type User = {
  firstName: string
  lastName: string
  age: number
  address: {
    streetAddress: string
    city: string
    state: string
    postalCode: string
  }
  contact: {
    type: string
    value: string
  }[]
}

静态类型声明是完全声明性的,并且与其序列化对应物完全匹配。我将在此处留下报价,以演示序列化的JSON字面上的有效字样:

const user: User = {
  "firstName": "Joe",
  "lastName": "Schmoe",
  "age": 35,
  "address": {
    "streetAddress": "123 Some St",
    "city": "Nowhere",
    "state": "NY",
    "postalCode": "12345"
  },
  "contact": [
    {
      "type": "phone",
      "value": "212-555-1234"
    },
    {
      "type": "email",
      "value": "joe.schmoe@example.com"
    }
  ]
}

TypeScript的此功能很大,但许多人忽略了意义。您可能没有意识到在完全相同的数据的表示之间投入的精神间接费用,但加起来。

当今使用的大多数静态型语言都无法表达这一点。从字面上看,以类似于Java,C#,Ruby,Rust,C或C ++的结构的方式来定义结构化数据。 scala,go和kotlin can 定义了嵌套的结构类型,但它们都非常笨拙,据我所知,它们都不允许创建一个没有命令代码的实例(欢迎评论)。

这是我们以声明格式的图像:

star image

作为数据模型的类问题

现在,让我们检查类作为数据模型的类别的一些问题。

他们与声明性语法不兼容

如果这个术语含糊不清,则最容易与命令性语法形成鲜明对比:

  • 命令代码描述了运行时必须采取的步骤
  • 声明代码描述了初始化对象的状态

我们如何实例化上面的const user: User = {是声明性的;我们正在以其完整的状态宣布该对象。另一方面,命令代码声明对象,然后描述了构建它的所有步骤:

const user = new User()
user.address = new Address()
user.address.streetAddress = '123 Some St'
user.address.city = 'Nowhere'
user.address.state = 'NY'
user.address.postalCode = '12345'

user.contact = []

const contact0 = new Contact()
contact0.value = '212-555-1234'
contact0.type = 'phone'
user.contact.push(contact0)

const contact1 = new Contact()
contact1.value = 'joe.schmoe@example.com'
contact1.type = 'email'
user.contact.push(contact1)

user.firstName = 'Joe'
user.lastName = 'Schmoe'
user.age = 35

如果您甚至花费很短的时间专注于以声明性的方式编写代码,那么明显的代码清晰度的好处就很明显。在许多语言中,命令风格是您的唯一选择,并且要直言不讳,即使我们的简单数据类型,这也是不可读的混乱。

除此之外,我们没有任何机制来确保我们实际上已经填写了一切,因为我们已经向编译器撒谎并告诉它所有的价值观都存在。它认为这只是重新分配值,而不是初始化。因此,这不仅成为可读性问题,而且成为功能正确性之一。

这使我成为最重要的缺点...

他们打破类型的安全!

您可能会想知道,类键入时,课程如何打破安全性?考虑类似的课程:

class Example {
  name: string
}

此类是没有有效的打字稿,因为它实际上是不可能创建有效实例的。该类型定义声称,给定Example实例,其name属性为string。但是你不能在那是真的的地方构造一个:

const instance = new Example()
// BROKEN!!!
console.log(instance.name.toUpperCase())

严格强制执行的null类型要比您从...嗯,大多数其他语言的背景中意识到的要重要得多。 Typescript不允许您在不确保其实际上是非无效的情况下声明某些事物(或未定义)。如果您一直在跑来跑去一半的TS编译器(严格的功能禁用),则可能并不明显。禁用的TS编译器选项strictPropertyInitialization忽略了这些违规行为,但它们不是有效的打字稿。

class Example {
  // Property 'name' has no initializer and is not
  // definitely assigned in the constructor. ts(2564)
  name: string
}

启用了完整的语言,您可以通过要求在施工时要求所有非视属性分配给定义的值来确保不得声明这样的类别的类别:

class Example {
  name: string

  constructor(name: string) {
    this.name = name
  }
}
const instance = new Example('Joe')
// SUCCESS!!!
console.log(instance.name.toUpperCase())

现在,对于少数成员来说,这是可以的。但是数据模型可能有数十个甚至数百个字段。要成为有效的类,您必须拥有一个接受对象的所有字段的构造函数。我们可以拥有数百个构造函数参数,并祈祷我们以正确的顺序获得它们,或者我们可以使用“命名参数”又称“参数对象”。但是,在数据对象的情况下,参数对象将是已经完全粘附到类型的对象。为了证明这种冗余的荒谬性,实际上没有比有一个类型是班级本身的论点更好的方式来宣布这样的构造者!

class Model {
  first: string
  second: string
  third: string
  fourth: number

  constructor(values: Model) {
    this.first = values.first
    this.second = values.second
    this.third = values.third
    this.fourth = values.fourth
  }
}

const params: Model = {
  first: 'one',
  second: 'two',
  third: 'three',
  fourth: 4,
}
const instance = new Model(params)

它们促进突变

ES6中添加的传播操作员是我使用过的任何语言中添加的最强大功能之一。它揭示并促进了执行副本修改的清晰,合成的方式,这意味着对不变实践的一流支持。我通常更喜欢避免教条,但是我坚持的是:尝试永远不要突变价值,并且绝对永远不会突变价值,除非您完全控制对象。您只有在变量是局部变量的情况下才能完全控制一个变量,甚至只有您还没有将其传递给其他任何地方。如果是这样,请突变您内心的满足。但是,如果您需要将对象传递到初始化,请考虑检索较小的对象并将它们组合在一起:

const createUser = (id: string): User => ({
  ...getNameAndAge(id),
  address: getAddress(id),
  contact: getContactInfo(id),
})

现在您永远不会有无效的对象实例。

课程完全破坏了这个奇妙的功能。例如,想象乔有一个生日,所以我们想更新乔的年龄。以声明性格式:

const updated: User = {
  ...user,
  age: 36,
}

这是一种干净而简洁的修改,可以完全理解,而无需踏上交互。您可以简单地将其阅读为“这是当前的用户,但年龄不同”。如果我们想用类执行相同类型的复制修改,那么,请堆叠更命令的代码:

const updated = new User()
updated.firstName = user.firstName
updated.lastName = user.lastName
updated.address = user.address
updated.contact = user.contact
updated.age = 36

如果在User中添加了新的东西,则您在这里再次没有类型的安全性。上面的传播是完全类型。如果您将pets部分添加到User,则无需更改。您的编译时间安全性没有省略参数,以及修改属性的类型安全性。在我们的后一个命令式示例中(需要禁用编译器的一部分),您需要寻找每个做到这一点的地方。

const updated = new User()
update.pets = user.pets
// ...

由于这种疼痛,压力很大,很强大:

user.age = 36

这将更改推向堆栈,向仍在代码的前一部分中持有此参考的任何人。这样做使所有代码非确定性。您可以在同一值上执行相同的操作,并且不知道您最终会做什么,因为系统的其他任何部分都可以从您下方拉出地毯。

比任何其他原因都要多,这就是为什么我们都担心“遗产”一词。围绕共享的可突变状态建立的软件系统在复杂性中生长呈指数。可能设置值的位置的数量是

numProperties ** numPlacesYouHavePassedIt

您甚至无法真正测试一个单元。它可能在没有其他零件的单位测试中工作,但是如果其他零件对其进行了修改,则您的测试是毫无用处的。甚至不可能识别所有代码路径。相反,使用不变实践的代码在复杂性中增长线性。该作品总是在中添加此值,并发送 value out。经过测试,完成,并进入下一部分。

此外,不变的做法实际上是软件系统实际工作方式的更好的镜子。当WebService接收request时,它不能简单地突变身体以向呼叫者提供反馈。它只能使用它来构建适当的response。线很清楚。 “我无法修改它,因为我没有创建它。”如果您以相同的方式对待功能参数,则整个系统将变得琐碎地重构为不同的服务。只有当他们依靠共享的状态参数集时,它们才变得难以分开。

装饰器进行救援引起更多问题

似乎对装饰者的兴趣越来越大。尽管实现有所不同,但应用程序应用的应用程序主要是Java注释的代名词。从本质上讲,它们允许将元数据附加到类及其字段和方法上。乍一看,这似乎是抽象一些处理数据的一些丑陋细节的好方法。验证,转换和翻译,映射到数据库实体等是一些更常见的。但是,重要的是要考虑这种模式的起源。

在Java中,您正在处理一种具有绝对没有类型的动态编程功能的语言。让它沉入。给定一个对象及其属性之一的名称,不可能以静态键入安全的方式检索该值。您可以在运行时检查它并丢弃错误,也可以编写恰好碰巧不会破坏它的代码,但是编译器非常不知道它...(实际上,Java编译器非常幸福地没有意识到 notingy 除非您明确告诉它,但是我离题了)。在打字稿中,所有本机动力功能均已完全键入检查。

const obj = { hello: 'world' }

// Type 'string' is not assignable to type 'number'. ts(2322)
const value: number = obj['hello']

// Type 'number' is not assignable to type 'string'. ts(2322)
obj.hello = 123

vs。

class Obj {
  public String hello;
}

// ...
Obj obj = new Obj();
obj.hello = "world";

Field field = Obj.class.getField("hello");
field.setAccessible(true);
// sure hope it's an int... the compiler doesn't know
int hello = field.getInt(obj);

好吧,装饰者正在用reflect-metadata重新引入所有同样的无型废话。反思是实施动态编程的绝对可怕方法。您将编译时间问题变成运行时问题。真的没有任何借口。除了“这就是我们在Java中所做的”之外,“推进”此功能没有合理的理由。为什么静态类型检查何时可以just add a comment to it

装饰的类必须实现ExceptionFilter接口。

import { Catch } from '@nestjs/common';

@Catch(String, null, Catch, () => 'monkeys like bananas')
export class ExceptionFilter extends Date {}

其中一些可以解决,但这是非常复杂的,而且装饰冠军肯定是地狱没有这样做的……而且其中有些只是不可能的。例如

@Injectable()
export class SomeService {
  constructor(@Inject(APPLE) apple: Orange) {}
}

无论如何,你都不能指望装饰师

重要的是要注意,JavaScript确实没有类。都是句法废话糖在物体创造上。 在“类”的实例和其他恰好到同一字段的其他对象之间没有显着的运行时区别。这很好...直到您开始在“无型元数据的魔术桶”中定义行为,这就是装饰者所做的。让我们探索:

说我们有一个称为Max的装饰器,该装饰器在设置数字值时,如果分配的值大于参数,则会引发错误。其实施的详细信息不超出本文的范围,但让我们认为它执行该规则。使用它看起来像:

class Material {
  name: string
  // decorator prevents setting the value to a number greater than 10
  @Max(10)
  quantity: number
}

// ...
material.quanity = 20 // ERROR!

装饰器在类定义中是静态定义的,因此我们知道,因为某些 Material的实例都不会具有大于10的quantity值...对吗?因此,我们试图依靠这种行为...

const NUCLEAR_REACTOR_MELTDOWN_THRESHOLD = 15

// We feel safe & secure...
const addNuclearMaterial = (material: Material) => {
  if (material.name === 'uranium' && reactor.uraniumCount <= 5) {
    try {
      reactor.addUranium(material.quantity)
    } catch (error) {
      log.info(
        'Too much uranium! Everyone would die!',
      )
    }
  }
}

addNuclearMaterial({ name: 'uranium', quantity: 20 })
// ensuing loud explosion

编译器无法区分使用new Material创建的对象和正确形状的常规对象。实际上,它甚至没有在“班级”本身中执行!

class Material {
  constructor(name: string, quantity: number) {
    return { name, quantity }
  }
  name: string

  @Max(10)
  quantity: number
}

因此,您不能指望装饰器中定义的任何行为,而无需检查对象是作为装饰类的实例而创建的。 简而言之,您无法真正依靠代码库中任何其他部分的装饰者定义的行为。

此外,无论如何,类型上的静态附件通常不是正确的位置。 10可能是一个用例的最大最大值,而不是另一种用例:

const addCoolant = (material: Material) => {
  reactor.addCoolant(material)
}

const material = new Material()
material.name = 'water'
material.quantity = 50

// we needed that much coolant, so... ensuing loud explosion
addCoolant(material)

我们实际上是什么?

即使在我们确实仅描述元数据的情况下,在其他情况下,静态类型的定义仍然是一个不好的地方。深入研究功能模式的范围不超出本文的范围,但是OO(或如今的使用方法)和功能编程之间的关键差异之一是后者在数据和行为之间有所区别。在oo中,您定义了“事物”,通常都是东西,而且做事。功能模式区分了数据,而功能是做事的。我认为后者是设计软件的更好方法,因为这是计算机系统实际工作方式。文件,http请求/响应,数据库等处理愚蠢的数据。程序,Web服务,功能描述了您要使用各种形式的数据的行为。

这种离题的目的是,装饰器将模型从简单的定义更改为数据和上下文行为的匹配。如果您添加了用于数据库详细信息的装饰器,则您只有用户,您就有一个UserInSomeSpecificDatabase。如果添加用于API文档,序列化和验证的装饰器,则有一个UserInSomeSpecificWebservice。有时,您只能逃脱两者(UserInSomeSpecificDatabaseInSomeSpecificWebservice),但是迟早,其中一些事情将开始冲突。

利益冲突

使用功能模式,每个函数都可以定义一些与某些数据进行的特定操作:

declare function readUserFromRequest(request: Request): User
declare function readUserFromDatabase(db: DbClient): User
declare function readUserFromTheStars(telescopeData: StarMapping): User

类型仅仅是输入或输出的定义。使用装饰器,您最终会在2个方案之一中出现:

最好的情况,它像地狱一样混乱。这是DB实体的现实示例,该实体也通过网络服务端点暴露:

class Address {
  @Column('street_address')
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  streetAddress: string

  @Column()
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  city: string

  @Column()
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  state: string

  @Column('postal_code')
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  postalCode: string
}

class Contact {
  @Column()
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  type: string

  @Column()
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  value: string
}

class User {
  @Column('first_name')
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  firstName: string

  @Column('first_name') // oops, wrong column
  @ApiProperty()
  @IsString()
  @IsNotEmpty()
  lastName: string

  @Column()
  @ApiProperty()
  @IsNumber()
  @Min(0)
  age: number

  @OneToOne(() => Address)
  @JoinColumn()
  @ApiProperty()
  @ValidateNested()
  @Type(() => Address)
  address: Address

  @OneToOne(() => Contact)
  @JoinColumn()
  @ApiProperty()
  @IsArray()
  @ValidateNested()
  @Type(() => Contact)
  contact: Contact[]
}

幸运的是,他们没有冲突(或更糟糕的是,也许他们这样做,但冲突却避免了您的测试)。这将我们带给了我们的其他选择:装饰者确实相互冲突,导致

类型增殖

当装饰器发生冲突时,您唯一的选择是将课程分为单独的上下文版本。您最终得到了JsonUserDbUserUserWhoOwnsADonkey。虽然不是一个行业接受的术语,但我将其称为“类型增殖”。

当您具有代表同一对象的多种类型时,必须将它们保持在同步中(当然,除了它们的微妙差异)。这些通常完全躲避编译器。像打字稿的类型别名和接口一样令人敬畏,允许对象文字表示,它们并不是由于可选参数而继续同步类同步的一个很好的机制。无法定义一个界面,该接口说“实现必须具有此可选参数”。考虑:

export interface User {
  firstName: string;
  lastName: string;
  age?: number;
  address: Address;
  contact: Contact[];
}

export class UserClass {
  firstName: string;
  lastName: string;
  // age?: number;  <-- no warning or error!!!!
  address: Address;
  contact: Contact[] = [];
}

静态键入的全部要点是,您可以对某物进行规范表示,但是这个上下文问题使得这是不可能的。规范表示的信誉太少了,但是当您拥有核心业务模型时,一致性就会具有巨大的价值。无论您使用的分布式系统的哪个部分,一个一致的定义意味着您可以指望名称和值类型相同。前端,后端没关系。即使在我们删除类型定义的上下文中,如果使用公共类型创建数据,您也可以简单安全地假设一致性,即使在不同的语言,请求实体,队列有效载荷,文件转储,NOSQL表,NOSQL表等。由于表示的一定二阶后果,数据的转换会导致不一致,这些导致错误。

简而言之,如果您的类型纯粹是类型的定义,则很容易将它们放在一个共同的位置。但是,如果它们是上下文装饰器的课程,那么他们最终到处都是。一个定义User的库不想包括与另一个相处的装饰者。 这使得无法共享规范表示。

概括

简而言之,使用类用于数据建模的类,毁了许多使打字稿成为出色语言的功能,并且绝对没有实质性的好处来应对限制。它们使您的代码不那么可读,更难遵循和灵活。我认为,任何人都倾向于这种模式的唯一原因是基于对唯一选择的其他语言的熟悉。在为这些语言设计的解决方案时,我们还在他们设计用于解决的这些语言的局限性上进行移植。同样,这一事实只是基于类的语言中同名构造的粗略近似,这意味着这些模式甚至不如育出它们的语言那样可靠。

接下来

在我们的下一集中,我们将介绍为什么课程不好...其他一切!