内容简介:The engineers at Asana have beenWe’ve learned a lot about where TypeScript shines and where it struggles. We love its powerful structural typing, popularity in open source projects, predictable output, and ease of adoption.On the other hand, I’ve become aw
The engineers at Asana have been TypeScript fans from very early on. We started using TypeScript version 0.9.1 in 2013, blogged about it in 2014 , wrote the first TypeScript bindings for React , and today have over 10,000 TypeScript files in our codebase. All of our new web frontend code is written in TypeScript and every product engineer at Asana learns it quickly upon joining the team.
We’ve learned a lot about where TypeScript shines and where it struggles. We love its powerful structural typing, popularity in open source projects, predictable output, and ease of adoption.
On the other hand, I’ve become aware of a lesser known problem in the language: TypeScript’s quirks and edge cases create a lot of confusion. TypeScript has a large number of special cases and surprises in the compiler that leave engineers scratching their heads and baffled. While individually these behaviors aren’t super damaging, as a whole they can make it more difficult for new engineers to form a mental model around the language and gain mastery.
Here are three of my favorite TypeScript surprises that seem to continually baffle engineers new to the language.
1. Interfaces with excess properties
At the core of TypeScript are object interfaces . These are types that represent objects with certain properties on them. For example dogs can be modeled as:
interface Dog { breed: string }
This says that Dogs
are objects that have a breed
property that is a string
. TypeScript is a structurally typed
language. This means that to create a Dog
you don’t need to explicitly extend the Dog
interface. Instead any object with a breed
property that is of type string
can be used as a Dog
.
One basic question you might ask is “Can Dogs
have additional properties aside from breed
?”. Unfortunately the TypeScript answer to this is complicated.
Say that we have a function
function printDog(dog: Dog) { console.log("Dog: " + dog.breed) }
Then it is okay to call the function like:
const ginger = { breed: "Airedale", age: 3 } printDog(ginger)
> Dog:Airedale
TypeScript understands that ginger
has 2 properties, including the required breed
property, so it happily considers ginger
to be a Dog
and compiles without a problem. From this example it would be reasonable to conclude that TypeScript allows excess properties.
On the other hand, look what happens when we define ginger
inline:
printDog({ breed: "Airedale", age: 3 }) Argument of type '{ breed: string; age: number; }' is not assignable to parameter of type 'Dog'. Object literal may only specify known properties, and 'age' does not exist in type 'Dog'.
What happened here? TypeScript takes the stance that interfaces are not strict; they can contain excess properties. At the same time, TypeScript endeavours to catch bugs where there are typos in property names or extra property names that do nothing. In the second example, TypeScript realized that even though the argument does match the Dog
interface, it’s probably not a useful thing to pass the age
property into the printDog
function and almost certainly represents a mistake. For more information on TypeScript’s stance here, see the documentation on Excess Property Checks
. You can see this behavior live in the TypeScript playground
.
While I think that the TypeScript stance here isn’t wrong (most excess properties are in fact bugs!) it does make the language more complex. Engineers can’t just think of interfaces as “objects that have exactly a set of properties” or “objects that have at least a set of properties”. They have to consider that inline object arguments receive an additional level of validation that doesn’t apply when they’re passed as variables.
2. Classes (nominal typing)
In addition to defining types as interfaces, TypeScript also creates types for classes. For example I could have instead defined a Dog
class like:
class Dog { breed: string constructor(breed: string) { this.breed = breed } }
After defining that, TypeScript allows you to use the class name as a type so you could write a function like:
function printDog(dog: Dog) { console.log("Dog: " + dog.breed) }
The interesting part comes in when you define other classes with the same properties. Say that we also have a Cat
class like:
class Cat { breed: string constructor(breed: string) { this.breed = breed } }
Surprisingly TypeScript will actually allow you to pass Cats
to functions that expect Dogs
:
const shasta = new Cat("Maine Coon") printDog(shasta)
> Dog:Maine Coon
The logic here is that because TypeScript is structurally typed, it only cares about the properties that objects contain (not how they were constructed). In this case that means that Dogs
are anything with a breed
property even if they were made with new Cat
. Here’s the example in the TypeScript playground
.
While this philosophy is consistent (everything is structurally typed) it is also quite surprising. Unless you are an engineer already familiar with TypeScript, you’d probably expect everything assignable to the Dog
type would either be created with new Dog
or one of its subclasses. Type assignability based on class names is called nominal typing
and is used by most other popular typed languages, such as Java as well as Flow
(Facebook’s answer to JavaScript with types). If you want to learn more about nominal typing, the Flow docs have a great description
.
TypeScript’s roadmap has contained an item to investigate nominal typing support for some time and I hope that it gets prioritized soon because until then, this remains another quirk of the language that every engineer will get bitten by eventually.
In the meantime, if you want to ensure that all Dogs
are made with new Dog
, you must resort to adding magical hidden properties or other type hacks. There are some good examples in TypeScript Deep Dive
and Nominal typing techniques in TypeScript
. We’ve put these to use in our code at Asana, but I don’t feel very good about them.
3. Discriminated Unions
My last example of surprising TypeScript behavior requires a few more concepts about the type system that we haven’t covered yet.
A
union type
is a type like Cat | Dog
that represents values that can be either a Cat
or a Dog
. This allows you to create typesafe functions like
function printCatOrDog(animal: Cat | Dog) {...}
A
string literal type
is a type that matches a single string. So in addition to "abc"
representing a string it can also refer to a type that matches exactly that string as well. These are particularly useful in conjunction with union types. For example we could have modeled the breed
property on Dog
like:
interface Dog { breed: "Airedale" | "Golden Retriever" | "Bulldog" }
This is pretty nifty because it prevents typos that could happen if we modeled breed
as a string.
Combining these together, TypeScript has a feature called discriminated unions . Quoting from the docs, there are 2 requirements for discriminated unions:
- Types that have a common, singleton type property — the discriminant.
- A type alias that takes the union of those types — the union.
Once you have these requirements, TypeScript will allow you to easily distinguish elements in the union when you check the property.
To make a concrete example, say that we want to model animals which can have a kind
of "cat"
or "dog"
and each has different properties:
interface Dog { kind: "dog" bark: string } interface Cat { kind: "cat" meow: string } type Animal = Cat | Dog
Now given an Animal
, we can use the kind property to distinguish which one we have. For example
function animalNoise(animal: Animal): string { // In this scope animal has type Animal // TypeScript won't allow us to access bark or meow because this animal might not have them if (animal.kind == "dog") { // Now that we've inspected the kind property, TypeScript knows the animal is a Dog, so we can safely access bark return animal.bark } else { // Here the compiler has determined the animal must be a Cat return animal.meow } }
This “type narrowing” is a pretty amazing feature, but if you are coming from languages without this type of feature, it can be pretty hard to wrap your head around. I encourage you to view this example in the TypeScript Playground
and hover over the various usages of animal
to see how its type changes in different scopes. Discriminated unions are important because they provide a way to determine the type of an object based on a runtime check which isn’t easy to do because types only exist at compile time.
While quite powerful, discriminated unions are also distressingly specific. The compiler is only willing to apply this special treatment under some very specific circumstances. For example, say that we want to instead model animals based on their species in a nested object:
interface Dog { taxonomy: { species: "Canis familiaris" } bark: string } interface Cat { taxonomy: { species: "Felis catus" } meow: string } type Animal = Cat | Dog function animalNoise(animal: Animal): string { if (animal.taxonomy.species == "Canis familiaris") { return animal.bark Property 'bark' does not exist on type 'Dog | Cat'. } else { return animal.meow Property 'meow' does not exist on type 'Dog | Cat'. } }
Here the discrimination fails! Despite the fact that we are checking the species and that property can only take on 2 possible values, the compiler refuses to narrow the type of animal
in the two branches of animalNoise
! Here it is in the playground
.
How did this happen? Discriminated unions only apply to properties on the top level on an object. The fact that we nested the information one level deeper meant that the TypeScript compiler was unable to notice that it could refine the type, so we are stuck with a surprising compilation error. Yuck!
Once again, I’ve seen too many engineers attempt to write code similar to the second example and been surprised it didn’t work.
How did we get here?
TypeScript is a language that is extremely popular and powerful, but also appears to have a lot of surprising behaviors and special cases. How did this happen?
Not having worked on the language at all, I can only guess. One major clue is the focus supporting migrations from JavaScript . TypeScript was designed to enable engineers to add types to their existing JavaScript code.
To accomplish this, the TypeScript team has iteratively added features to allow increasing amounts of real-world JavaScript code to become typesafe. Each new release has added more language features and tightened the type checker to catch more pieces of unsafe code.
Behind the scenes, I fear that each of these incremental improvements may have also allowed TypeScript to slowly grow in complexity. Real world JavaScript is inconsistent, messy, and complicated and I think that TypeScript may have gone too far to support it.
For instance, I suspect that discriminated unions were added to TypeScript to support existing JavaScript code that used this pattern. As we saw in Quirk 3, discriminated unions only were implemented for properties one level deep (likely because a fully featured implementation could be difficult to build and slow to type check). Discriminated unions enabled a wider class of JavaScript to be converted to TypeScript but at the cost of adding another set of inconsistencies to the language. Over time these little inconsistencies have added up to real complexity.
TypeScript has an explicit design goal around simplicity,
Goal 5: Produce a language that is composable and easy to reason about.
but I fear that this vision isn’t currently achieved.
Should you still use TypeScript?
If you care about a perfect, consistent type system, TypeScript probably isn’t for you ( Reason seems like it could be good). But if you want to add some type safety to an existing JavaScript codebase while leveraging a large, thriving open source community, then TypeScript is still your best bet.
If you decide to use TypeScript, expect engineers on your team to have a good amount of head-scratching and wasted time reasoning about the compiler, but not nearly so much as to offset the amazing value that static types provide.
Longer term I hope that the TypeScript team will work to simplify and tighten up the language. They have recently fixed some of the worst sources of confusion (with –strictFunctionTypes for instance) but reshaping TypeScript into a simple, consistent language will take a concerted effort.
以上所述就是小编给大家介绍的《TypeScript’s quirks: How inconsistencies make the language morecomplex》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
CSS 压缩/解压工具
在线压缩/解压 CSS 代码
HEX HSV 转换工具
HEX HSV 互换工具