diff --git a/docusaurus.config.js b/docusaurus.config.js index ad84239..14544f8 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -126,12 +126,6 @@ const config = { url: "https://www.learningtypescript.com", presets: [ - [ - "docusaurus-preset-shiki-twoslash", - { - themes: ["min-light", "nord"], - }, - ], [ "classic", /** @type {import('@docusaurus/preset-classic').Options} */ @@ -164,6 +158,12 @@ const config = { }, }), ], + [ + "docusaurus-preset-shiki-twoslash", + { + themes: ["min-light", "nord"], + }, + ], ], themeConfig: diff --git a/src/content/articles/for-in-string-types.mdx b/src/content/articles/for-in-string-types.mdx new file mode 100644 index 0000000..5e2b8f3 --- /dev/null +++ b/src/content/articles/for-in-string-types.mdx @@ -0,0 +1,212 @@ +--- +date: "February 25 2023" +description: "Why TypeScript reports a `string` type instead of `keyof typeof` for some forms of loops over objects." +meta: keyof, loops, objects, strings +--- + +# Why `for...in` and `Object.keys()` Give `string` Types + +Why does TypeScript report a `string` type instead of `keyof typeof` the object when you use a `for...in` loop or `Object.keys()` on an object? + +One of the most commonly asked questions in TypeScript help forums is why code like the following gives an error under the [`noImplicitAny` compiler option](https://www.typescriptlang.org/tsconfig#noImplicitAny). + +```ts twoslash +// @errors: 7053 +const counts = { + one: 1, + two: 2, +}; + +for (const i in counts) { + // ^? + console.log(counts[i]); +} +``` + +Shouldn't TypeScript know that `i` is type `"one" | "two"`, a.k.a. `keyof typeof counts`? +Why is it telling us that `i` is `string` and can't be used like `counts[i]`? + +The answer is logical -though often inconvenient- and requires understanding a key characteristic of TypeScript's type system. + + + +## Objects May Have More Properties Than They Appear + +TypeScript's type system is _structurally typed_: meaning any value that happens to satisfy a type is allowed to be used as a value of that type. +In other words, when you declare that a value is of a particular object type, you're telling TypeScript any object that just so happens to have those properties is allowed to be considered that type. + +As a result, when you write a `for...in` loop over the keys of an object, you often won't have a guarantee that the object's keys will only be from the type of the object. +Some other area of code might have provided an object with more properties that just so happens to match the structural type. + +Take a look at this completely type safe code snippet that passes an object to a function. +What types would you expect for `i` inside the function's `for` loop? + +```ts twoslash +interface TwoCounts { + one: number; + two: number; +} + +function iterateOverCounts(counts: TwoCounts) { + for (const i in counts) { + // ... + } +} + +const counts = { + one: 1, + two: 2, + three: 3, +}; + +iterateOverCounts(counts); +``` + +`i` at _runtime_ will be `"one"`, `"two"`, then `"three"`. +But there's no reasonable way for TypeScript's type system to know that. +It just knows that `counts` is type `TwoCounts`, which has keys `"one"` and `"two"`. + +Thus, if TypeScript tried to assume that `i` was `keyof typeof counts` then the type system could be insidiously wrong. +For example, your code might assume `i` is only ever `"one"` or `"two`", and use it in logic that would crash with any other value! + +See [Workarounds](#workarounds) below for how you can get around this behavior in `for...in` loops. + +:::tip +Wondering why the first code snippet couldn't narrow `counts` to a more precise type? +See also [Why Objects Aren't Narrowed Before Usage](/articles/objects-narrowed-before-usage). +::: + +### `Object.keys()` Too + +This same design quirk applies to `Object.keys()`: + +```ts twoslash +const counts = { + one: 1, + two: 2, +}; + +Object.keys(counts); +// ^? +``` + +`Object.keys()` and `for...in` loops work almost the same way. +The main difference is that `Object.keys()` only looks at properties on the object itself, while `for...in` loops also look at properties on the object's `prototype` chain. + +See [Workarounds](#workarounds) below for how you can get around this behavior in `Object.keys()` as well. + +## Workarounds + +Before you try to work around the design of the type checker, please pause a moment and reflect. +The situations mentioned earlier in this post are real problems in JavaScript and TypeScript code that have caused countless bugs in software. + +**TypeScript is right to assume `string`s types.** + +### `Object` APIs + +TypeScript's type checking is designed to work with how JavaScript data typically flows and changes through an application. +Needing to wrestle with the type checker is often a byproduct of application code going against safe JavaScript practices -- such as the aforementioned issues with `for...in` loops receiving unexpected key strings. +In many cases, it might be good to listen to TypeScript and consider refactoring your code to use a JavaScript API better suited to the task. + +If all your code needs to do is get all the values in an object, an `Object.values()` API exists that returns those as an array. +You can `for...of` loop over them instead: + +```ts twoslash +// @lib: dom,esnext + +const counts = { + one: 1, + two: 2, +}; + +for (const [key, value] of Object.entries(counts)) { + // ^? +} +``` + +Alternately, if you do need keys, consider using the `Object.entries()` API instead. +It returns a `[string, T][]` where `T` is the type of values in an object: + +```ts twoslash +// @lib: dom,esnext + +const counts = { + one: 1, + two: 2, +}; + +for (const [key, value] of Object.entries(counts)) { + // ^? +} +``` + +Both `Object.entries()` and `Object.values()` allow you to write clean, idiomatic JavaScript without having to grapple with the type checker. + +### `Map` + +Taking an even larger step back, why is the application code looping over an object's keys and values? +Is the object meant to be used as a general key-value store? + +If so: consider using the built-in `Map` data structure instead. +It's made specifically for key-value stores. + +You can `for...of` over a `Map`'s values using `Array.from`: + +```ts twoslash +// @lib: dom,esnext + +const counts = new Map([ + ["one", 1], + ["two", 2], +]); + +for (const [key, value] of Array.from(counts)) { + // ^? +} +``` + +`Map`s have the added benefit over `{}` objects of allowing any object to be a key without stringifying them first (which objects do). + +### Type Assertions + +Still, there are times when a `for...in` loop or `Object.keys()` might likely be mostly safe to use for object keys. +If you are 100% absolutely completely sure that your use case won't crash from some bizarre type mismatch now or in the future, you can always use an `as` assertion to cast the loop variable to the right type. + +One assertion strategy is to use `keyof typeof` to get the keys (`keyof`) of the object's type (`typeof`): + +```ts twoslash +const counts = { + one: 1, + two: 2, +}; + +for (const i in counts) { + console.log(counts[i as keyof typeof counts]); // Ok +} +``` + +Aternately, if the object you're looping over is of a previously declared type, you can use the `keyof` operator on that type to get back a string literal union of its keys: + +```ts twoslash +interface Counts { + one: number; + two: number; +} + +const counts: Counts = { + one: 1, + two: 2, +}; + +for (const i in counts) { + console.log(counts[i as keyof Counts]); // Ok +} +``` + +## Further Reading + +See [this comment from Anders Hejlsberg](https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208) and its surrounding PR for more details to this blog post on why the TypeScript team opted to keep this safer behavior. + +The TypeScript team and community have been discussing a [pending issue for "Exact" types](https://github.com/microsoft/TypeScript/issues/12936). +Exact types wouldn't allow arbitrary extra keys, so `for...in` loops over them would give the more narrow key types. +The issue has been open since 2016, though, so don't get your hopes up 😉. diff --git a/src/content/articles/object-properties-narrowed-before-usage.mdx b/src/content/articles/object-properties-narrowed-before-usage.mdx new file mode 100644 index 0000000..e37662f --- /dev/null +++ b/src/content/articles/object-properties-narrowed-before-usage.mdx @@ -0,0 +1,136 @@ +--- +date: "February 17 2023" +description: "Limitations of TypeScript's type narrowing when it comes to objects." +meta: literals, narrowing, objects, primitive +--- + +# Why Values Aren't Narrowed Before Usage + +TypeScript's type system boasts a feature called _type narrowing_. +Type narrowing means that if a block of code can only be reached when a value is a specific type, then the value will be considered that type inside the block. + +As an example, take the following `fruit` variable of type `string | undefined` (a union type indicating it can be either `string` or `undefined`. +TypeScript can deduce that inside the `if` statement, `fruit` must be truthy, which means its type inside that block must be the more narrow (specific) `string`. + +```ts twoslash +let fruit = Math.random() > 0.5 ? "apple" : undefined; + +fruit; +// ^? + +if (fruit) { + fruit; + // ^? +} +``` + +TypeScript's type narrowing is quite useful - but it has limitations in what it can infer. +Most notably, it is sometimes wrong about the types of values after functions are called to modify them. + +```ts twoslash +let vegetable: string | undefined; + +function setVegetable(newValue: string) { + vegetable = newValue; +} + +setVegetable("zucchini"); + +vegetable; +// ^? +``` + +Shouldn't TypeScript know that `vegetable` is specifically `string` after `setVegetable` has been called, not `string | undefined`? +Can't it tell we haven't changed the object yet? + +It can't, but it won't. +And for very good reason. + + + +## Change Tracking is Impractical + +The problem is that it's frequently difficult -sometimes impossible- for TypeScript to know whether a value has been modified after its creation. +If the value is used in any meaningful application logic, TypeScript won't be able to tell whether it was modified. +That means TypeScript often can't be sure whether an value is still the same value it started with, even a few lines later. + +Take this more convoluted version of the `setVegetable` example: + +```ts twoslash +let vegetable: string | undefined; + +function setVegetable(newValue: string) { + vegetable = newValue; +} + +setVegetable("zucchini"); + +vegetable; +// ^? +``` + +### A Practical Example + +Let's say you want to log a `counts` object after creating it and before you reassign any values. +You might pass it to a `log` function that does something like call to `console.log`: + +```ts twoslash +function log(counts: Record) { + console.log(counts); +} + +const foods = { + apple: 1, + zucchini: 2, +}; + +foods.zucchini; +// ^? + +log(foods); + +foods.zucchini; +// ^? +``` + +Unfortunately, it would be quite difficult for TypeScript to reliably determine whether the `log` function modifies its `data` parameter. +The code in this snippet was only one line - but what if the function provides `counts` to potentially several other functions? +What if those functions are only declared in `.d.ts` files that don't describe the contents of the functions? + +In theory, the `log` function could use the `readonly` operator on its `data` parameter to indicate that `data` is never modified. +But adding `readonly`s everywhere is inconvenient. +Many community-written type definitions don't properly mark parameters as read-only. +Even `console.log` isn't marked as having read-only parameters as of TypeScript 5's built-in type definitions. + +## TypeScript's Compromise + +As a result, TypeScript has made two intentional design decisions + +## Further Reading + +A legendary [Trade-offs in Control Flow Analysis](https://github.com/microsoft/TypeScript/issues/9998) issue exists in the TypeScript repository. +It describes many more common trade-offs in how the TypeScript type checker analyzes the flow of values. + +--- + +## Object Properties and Mutability ?? + +Object properties undergo similar type inference logic to variables. +If an object property is _mutable_, such as an object created in a `const` variable, TypeScript will infer a _primitive_ type, not a _literal_. +Only for _immutable_ objects, such as those declared with an `as const`, will TypeScript infer all properties to be their _literal_ types. + +```ts twoslash +const fruits = { + apple: 1, +}; + +fruits.apple; +// ^? + +const vegetables = { + zucchini: 2, +} as const; + +vegetables.zucchini; +// ^? +``` diff --git a/src/content/articles/objects-narrowed-before-usage.mdx b/src/content/articles/objects-narrowed-before-usage.mdx new file mode 100644 index 0000000..d912781 --- /dev/null +++ b/src/content/articles/objects-narrowed-before-usage.mdx @@ -0,0 +1,95 @@ +--- +date: "February 17 2023" +description: "Why TypeScript's type narrowing doesn't apply to object properties that have just been created." +meta: literals, narrowing, objects, primitives +--- + +# Why Object Properties Aren't Narrowed Before Usage + +TypeScript's type system boasts a powerful trio of features called _primitives_, _literals_, and _union types_: + +- _Primitives_ are types representing the basic immutable value types from JavaScript: `boolean`, `number`, `string`, etc. +- _Literals_ are types representing specific instances of primitives: `true`, `123`, `"abc"`, etc. +- _Union types_ are types representing values that could be one of several possible types, such as `string | undefined` representing a value that can be `string` or `undefined`. + +> You can think of each _primitive_ as being a _union_ of all the _literals_ of that type. + +When you create an object in a TypeScript file, TypeScript's type system needs to figure out what type the object's properties are allowed to be. +TypeScript will by default use _primitive_ types _(`number`, `string`, ...)_ instead of _literal_ types _(`123`, `"abc"`, ...)_ for properties with a literal value. + +For example, `counts.zucchini` in this object is considered to be type `number`, not `2`: + +```ts twoslash +const counts = { + apple: 1, + zucchini: 2, +}; + +counts.zucchini; +// ^? +``` + +Shouldn't TypeScript know that `counts.zucchini` starts off as the specific literal type `2`, not the general primitive type `number`? +Can't it tell we haven't changed the object yet? + +It can't, but it won't. +And for very good reason. + + + +## Change Tracking is Impractical + +The problem is that it's difficult -oftentimes impossible- for TypeScript to know whether an object has been modified if anything has happened between its initialization and first usage. +If the object is used in any meaningful application logic, TypeScript won't be able to tell whether it was modified. + +### A Practical Example + +Let's say you want to `console.log` the `counts` object before reassigning a new value to a count. +Your use your own custom `log` function that just so happens to only call `console.log`? +You'd still want `counts.zucchini` to be `2` after `log(counts);`, right? + +```ts +function log(data: unknown) { + console.log(data); +} + +const counts = { + apple: 1, + zucchini: 2, +}; + +log(counts); + +// You'd want this to be 2, right? +counts.zucchini; +``` + +Unfortunately, it could become quite difficult for TypeScript to reliably determine whether the `log` function modifies its `data` parameter. +The code in this snippet was only one line - but what if the function provides `counts` to potentially several other functions? +What if those functions are only declared in `.d.ts` files that don't describe the contents of the functions? + +```ts +// Defined as: +// function someFancyFunction(data: Record): void; +import { someFancyFunction } from "@joshuakgoldberg/some-fancy-library"; + +// Does this mutate `data`? Heck if we know! 🤷 +function log(data: Record) { + someFancyFunction(data); +} +``` + +In theory, the `log` function could use the `readonly` operator on its `data` parameter to indicate that `data` is never modified. +But adding `readonly`s all over is inconvenient. +Many community-written type definitions don't properly mark parameters as read-only. +Even `console.log` isn't marked as having read-only parameters as of TypeScript 5's built-in type definitions. + +## A Compromise For Consistency + +As a result, TypeScript's type system has the intentional + +## Further Reading + +A legendary [Trade-offs in Control Flow Analysis](https://github.com/microsoft/TypeScript/issues/9998) issue exists in the TypeScript repository. +It describes many more common trade-offs in how the TypeScript type checker analyzes the flow of values. +It's a great read if you're interested in understanding more of the edge cases around object properties and type narrowing. diff --git a/src/content/articles/values-narrowed-before-usage.mdx b/src/content/articles/values-narrowed-before-usage.mdx new file mode 100644 index 0000000..3a3f430 --- /dev/null +++ b/src/content/articles/values-narrowed-before-usage.mdx @@ -0,0 +1,112 @@ +--- +date: "February 17 2023" +description: "Why TypeScript's type narrowing won't apply to ." +meta: literals, narrowing, objects, primitive +--- + +# Why Values Aren't Narrowed After Function Calls + +TypeScript's type system boasts a feature called _type narrowing_. +Type narrowing means that if a block of code can only be reached when a value is a specific type, then the value will be considered that type inside the block. + +As an example, take the following `fruit` variable of type `string | undefined` (a union type indicating it can be either `string` or `undefined`. +TypeScript can deduce that inside the `if` statement, `fruit` must be truthy, which means its type inside that block must be the more narrow (specific) `string`. + +```ts twoslash +let fruit = Math.random() > 0.5 ? "apple" : undefined; + +fruit; +// ^? + +if (fruit) { + fruit; + // ^? +} +``` + +TypeScript's type narrowing is quite useful - but it has limitations in what it can infer. +Most notably, it is sometimes wrong about the types of values after functions are called to modify them. + +```ts twoslash +let vegetable: string | undefined; + +function setVegetable(newValue: string) { + vegetable = newValue; +} + +setVegetable("zucchini"); + +vegetable; +// ^? +``` + +Shouldn't TypeScript know that `vegetable` is specifically `string` after `setVegetable` has been called, not `string | undefined`? +Can't it tell we haven't changed the object yet? + +It can't, but it won't. +And for very good reason. + + + +## Change Tracking is Impractical + +The problem is that it's frequently difficult -sometimes impossible- for TypeScript to know whether a value has been modified after its creation. +If the value is used in any meaningful application logic, TypeScript won't be able to tell whether it was modified. +That means TypeScript often can't be sure whether an value is still the same value it started with, even a few lines later. + +Take this more convoluted version of the `setVegetable` example: + +```ts twoslash +let vegetable: string | undefined; + +function setVegetable(newValue: string) { + vegetable = newValue; +} + +setVegetable("zucchini"); + +vegetable; +// ^? +``` + +### A Practical Example + +Let's say you want to log a `counts` object after creating it and before you reassign any values. +You might pass it to a `log` function that does something like call to `console.log`: + +```ts twoslash +function log(counts: Record) { + console.log(counts); +} + +const foods = { + apple: 1, + zucchini: 2, +}; + +foods.zucchini; +// ^? + +log(foods); + +foods.zucchini; +// ^? +``` + +Unfortunately, it would be quite difficult for TypeScript to reliably determine whether the `log` function modifies its `data` parameter. +The code in this snippet was only one line - but what if the function provides `counts` to potentially several other functions? +What if those functions are only declared in `.d.ts` files that don't describe the contents of the functions? + +In theory, the `log` function could use the `readonly` operator on its `data` parameter to indicate that `data` is never modified. +But adding `readonly`s everywhere is inconvenient. +Many community-written type definitions don't properly mark parameters as read-only. +Even `console.log` isn't marked as having read-only parameters as of TypeScript 5's built-in type definitions. + +## TypeScript's Compromise + +As a result, TypeScript has made two intentional design decisions + +## Further Reading + +A legendary [Trade-offs in Control Flow Analysis](https://github.com/microsoft/TypeScript/issues/9998) issue exists in the TypeScript repository. +It describes many more common trade-offs in how the TypeScript type checker analyzes the flow of values.