Today we’re announcing the availability of TypeScript 3.8 Beta! This Beta release contains all the new features you should expect from TypeScript 3.8’s final release.
To get started using the beta, you can get it through NuGet, or through npm with the following command:
npm install typescript@beta
You can also get editor support by
TypeScript 3.8 brings a lot of new features, including new or upcoming ECMAScript standards features, new syntax for importing/exporting only types, and more.
Type-Only Imports and Export
TypeScript reuses JavaScript’s import syntax in order to let us reference types. For instance, in the following example, we’re able to import doThing
which is a JavaScript value along with Options
which is purely a TypeScript type.
// ./foo.ts
interface Options {
// ...
}
export function doThing(options: Options) {
// ...
}
// ./bar.ts
import { doThing, Options } from "./foo.js";
function doThingBetter(options: Options) {
// do something twice as good
doThing(options);
doThing(options);
}
This is convenient because most of the time we don’t have to worry about what’s being imported – just that we’re importing something.
Unfortunately, this only worked because of a feature called import elision. When TypeScript outputs JavaScript files, it sees that Options
is only used as a type, and it automatically drops its import. The resulting output looks kind of like this:
// ./foo.js
export function doThing(options: Options) {
// ...
}
// ./bar.js
import { doThing } from "./foo.js";
function doThingBetter(options: Options) {
// do something twice as good
doThing(options);
doThing(options);
}
Again, this behavior is usually great, but it causes some other problems.
First of all, there are some places where it’s ambiguous whether a value or a type is being exported. For example, in the following example is MyThing
a value or a type?
import { MyThing } from "./some-module.js";
export { MyThing };
Limiting ourselves to just this file, there’s no way to know. Both Babel and TypeScript’s transpileModule
API will emit code that doesn’t work correctly if MyThing
is only a type, and TypeScript’s isolatedModules
flag will warn us that it’ll be a problem. The real problem here is that there’s no way to say “no, no, I really only meant the type – this should be erased”, so import elision isn’t good enough.
The other issue was that TypeScript’s import elision would get rid of import statements that only contained imports used as types. That caused observably different behavior for modules that have side-effects, and so users would have to insert a second import statement purely to ensure side-effects.
// This statement will get erased because of import elision.
import { SomeTypeFoo, SomeOtherTypeBar } from "./module-with-side-effects";
// This statement always sticks around.
import "./module-with-side-effects";
A concrete place where we saw this coming up was in frameworks like Angular.js (1.x) where services needed to be registered globally (which is a side-effect), but where those services were only import
ed for types.
// ./service.ts
export class Service {
// ...
}
register("globalServiceId", Service);
// ./consumer.ts
import { Service } from "./service.js";
inject("globalServiceId", function (service: Service) {
// do stuff with Service
});
As a result, ./service.js
will never get run, and things will break at runtime.
To avoid this class of issues, we realized we needed to give users more fine-grained control over how things were getting imported/elided.
As a solution in TypeScript 3.8, we’ve added a new syntax for type-only imports and exports.
import type { SomeThing } from "./some-module.js";
export type { SomeThing };
import type
only imports declarations to be used for type annotations and declarations. It always gets fully erased, so there’s no remnant of it at runtime. Similarly, export type
only provides an export that can be used for type contexts, and is also erased from TypeScript’s output.
It’s important to note that classes have a value at runtime and a type at design-time, and the use is very context-sensitive. When using import type
to import a class, you can’t do things like extend from it.
import type { Component } from "react";
interface ButtonProps {
// ...
}
class Button extends Component<ButtonProps> {
// ~~~~~~~~~
// error! 'Component' only refers to a type, but is being used as a value here.
// ...
}
If you’ve used Flow before, the syntax is fairly similar. One difference is that we’ve added a few restrictions to avoid code that might appear ambiguous.
// Is only 'Foo' a type? Or every declaration in the import?
// We just give an error because it's not clear.
import type Foo, { Bar, Baz } from "some-module";
// ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.
In conjunction with import type
, we’ve also added a new compiler flag to control what happens with imports that won’t be utilized at runtime: importsNotUsedAsValues
. At this point the name is tentative, but this flag takes 3 different options:
remove
: this is today’s behavior of dropping these imports. It’s going to continue to be the default, and is a non-breaking change.
preserve
: this preserves all imports whose values are never used. This can cause imports/side-effects to be preserved.
error
: this preserves all imports (the same as the preserve
option), but will error when a value import is only used as a type. This might be useful if you want to ensure no values are being accidentally imported, but still make side-effect imports explicit.
For more information about the feature, you can take a look at the pull request.
Type-Only vs Erased
There is a final note about this feature. In TypeScript 3.8 Beta, only the type meaning of a declaration will be imported by import type
. That means that you can’t use values even if they’re purely used for type positions (like in the extends
clause of a class
declared with the declare
modifier, and the typeof
type operator).
import type { Base } from "my-library";
let baseConstructor: typeof Base;
// ~~~~
// error! 'Base' only refers to a type, but is being used as a value here.
declare class Derived extends Base {
// ~~~~
// error! 'Base' only refers to a type, but is being used as a value here.
}
We’re looking at changing this behavior based on recent feedback. Instead of only importing the type side of declarations, we’re planning on changing the meaning of import type
to mean “import whatever this is, but only allow it in type positions.” In other words, things imported using import type
can only be used in places where it won’t affect surrounding JavaScript code.
While this behavior is not in the beta, you can expect it in our upcoming release candidate, and keep track of that work on its respective pull request.
ECMAScript Private Fields
TypeScript 3.8 brings support for ECMAScript’s private fields, part of the stage-3 class fields proposal. This work was started and driven to completion by our good friends at Bloomberg!
class Person {
#name: string
constructor(name: string) {
this.#name = name;
}
greet() {
console.log(`Hello, my name is ${this.#name}!`);
}
}
let jeremy = new Person("Jeremy Bearimy");
jeremy.#name
// ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.
Unlike regular properties (even ones declared with the private
modifier), private fields have a few rules to keep in mind. Some of them are:
- Private fields start with a
#
character. Sometimes we call these private names.
- Every private field name is uniquely scoped to its containing class.
- TypeScript accessibility modifiers like
public
or private
can’t be used on private fields.
- Private fields can’t be accessed or even detected outside of the containing class – even by JS users! Sometimes we call this hard privacy.
Apart from “hard” privacy, another benefit of private fields is that uniqueness we just mentioned. For example, regular property declarations are prone to being overwritten in subclasses.
class C {
foo = 10;
cHelper() {
return this.foo;
}
}
class D extends C {
foo = 20;
dHelper() {
return this.foo;
}
}
let instance = new D();
// 'this.foo' refers to the same property on each instance.
console.log(instance.cHelper()); // prints '20'
console.log(instance.dHelper()); // prints '20'
With private fields, you’ll never have to worry about this, since each field name is unique to the containing class.
class C {
#foo = 10;
cHelper() {
return this.#foo;
}
}
class D extends C {
#foo = 20;
dHelper() {
return this.#foo;
}
}
let instance = new D();
// 'this.#foo' refers to a different field within each class.
console.log(instance.cHelper()); // prints '10'
console.log(instance.dHelper()); // prints '20'
Another thing worth noting is that accessing a private field on any other type will result in a TypeError
!
class Square {
#sideLength: number;
constructor(sideLength: number) {
this.#sideLength = sideLength;
}
equals(other: any) {
return this.#sideLength === other.#sideLength;
}
}
const a = new Square(100);
const b = { sideLength: 100 };
// Boom!
// TypeError: attempted to get private field on non-instance
// This fails because 'b' is not an instance of 'Square'.
console.log(a.equals(b));
Finally, for any plain .js
file users, private fields always have to be declared before they’re assigned to.
class C {
// No declaration for '#foo'
// :(
constructor(foo: number) {
// SyntaxError!
// '#foo' needs to be declared before writing to it.
this.#foo = foo;
}
}
JavaScript has always allowed users to access undeclared properties, whereas TypeScript has always required declarations for class properties. With private fields, declarations are always needed regardless of whether we’re working in .js
or .ts
files.
class C {
/** @type {number} */
#foo;
constructor(foo: number) {
// This works.
this.#foo = foo;
}
}
For more information about the implementation, you can check out the original pull request
Which should I use?
We’ve already received many questions on which type of privates you should use as a TypeScript user: most commonly, “should I use the private
keyword, or ECMAScript’s hash/pound (#
) private fields?”
Like all good questions, the answer is not good: it depends!
When it comes to properties, TypeScript’s private
modifiers are fully erased – that means that while the data will be there, nothing is encoded in your JavaScript output about how the property was declared. At runtime, it acts entirely like a normal property. That means that when using the private
keyword, privacy is only enforced at compile-time/design-time, and for JavaScript consumers, it’s entirely intent-based.
class C {
private foo = 10;
}
// This is an error at compile time,
// but when TypeScript outputs .js files,
// it'll run fine and print '10'.
console.log(new C().foo); // prints '10'
// ~~~
// error! Property 'foo' is private and only accessible within class 'C'.
// TypeScript allows this at compile-time
// as a "work-around" to avoid the error.
console.log(new C()["foo"]); // prints '10'
The upside is that this sort of “soft privacy” can help your consumers temporarily work around not having access to some API, and works in any runtime.
On the other hand, ECMAScript’s #
privates are completely inaccessible outside of the class.
class C {
#foo = 10;
}
console.log(new C().#foo); // SyntaxError
// ~~~~
// TypeScript reports an error *and*
// this won't work at runtime!
console.log(new C()["#foo"]); // prints undefined
// ~~~~~~~~~~~~~~~
// TypeScript reports an error under 'noImplicitAny',
// and this prints 'undefined'.
This hard privacy is really useful for strictly ensuring that nobody can take use of any of your internals. If you’re a library author, removing or renaming a private field should never cause a breaking change.
As we mentioned, another benefit is that subclassing can be easier with ECMAScript’s #
privates because they really are private. When using ECMAScript #
private fields, no subclass ever has to worry about collisions in field naming. When it comes to TypeScript’s private
property declarations, users still have to be careful not to trample over properties declared in superclasses.
Finally, something to consider is where you intend for your code to run. TypeScript currently can’t support this feature unless targeting ECMAScript 2015 (ES6) targets or higher. This is because our downleveled implementation uses WeakMap
s to enforce privacy, and WeakMap
s can’t be polyfilled in a way that doesn’t cause memory leaks. In contrast, TypeScript’s private
-declared properties work with all targets – even ECMAScript 3!
Kudos!
It’s worth reiterating how much work went into this feature from our contributors at Bloomberg. They were diligent in taking the time to learn to contribute features to the compiler/language service, and paid close attention to the ECMAScript specification to test that the feature was implemented in compliant manner. They even improved another 3rd party project, CLA Assistant, which made contributing to TypeScript even easier.
We’d like to extend a special thanks to:
export * as ns
Syntax
It’s often common to have a single entry-point that exposes all the members of another module as a single member.
import * as utilities from "./utilities.js";
export { utilities };
This is so common that ECMAScript 2020 recently added a new syntax to support this pattern!
export * as utilities from "./utilities.js";
This is a nice quality-of-life improvement to JavaScript, and TypeScript 3.8 implements this syntax. When your module target is earlier than es2020
, TypeScript will output something along the lines of the first code snippet.
Special thanks to community member Wenlu Wang (Kingwl) who implemented this feature! For more information, check out the original pull request.
Top-Level await
Most modern environments that provide I/O in JavaScript (like HTTP requests) is asynchronous, and many modern APIs return Promise
s. While this has a lot of benefits in making operations non-blocking, it makes certain things like loading files or external content surprisingly tedious.
fetch("...")
.then(response => response.text())
.then(greeting => { console.log(greeting) });
To avoid .then
chains with Promise
s, JavaScript users often introduced an async
function in order to use await
, and then immediately called the function after defining it.
async function main() {
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
}
main()
.catch(e => console.error(e))
To avoid introducing an async
function, we can use a handy upcoming ECMAScript feature called “top-level await
“.
Previously in JavaScript (along with most other languages with a similar feature), await
was only allowed within the body of an async
function. However, with top-level await
, we can use await
at the top level of a module.
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
// Make sure we're a module
export {};
Note there’s a subtlety: top-level await
only works at the top level of a module, and files are only considered modules when TypeScript finds an import
or an export
. In some basic cases, you might need to write out export {}
as some boilerplate to make sure of this.
Top level await
may not work in all environments where you might expect at this point. Currently, you can only use top level await
when the target
compiler option is es2017
or above, and module
is esnext
or system
. Support within several environments and bundlers may be limited or may require enabling experimental support.
For more information on our implementation, you can check out the original pull request.
es2020
for target
and module
Thanks to Kagami Sascha Rosylight (saschanaz), TypeScript 3.8 supports es2020
as an option for module
and target
. This will preserve newer ECMAScript 2020 features like optional chaining, nullish coalescing, export * as ns
, and dynamic import(...)
syntax. It also means bigint
literals now have a stable target
below esnext
.
JSDoc Property Modifiers
TypeScript 3.8 supports JavaScript files by turning on the allowJs
flag, and also supports type-checking those JavaScript files via the checkJs
option or by adding a // @ts-check
comment to the top of your .js
files.
Because JavaScript files don’t have dedicated syntax for type-checking, TypeScript leverages JSDoc. TypeScript 3.8 understands a few new JSDoc tags for properties.
First are the accessibility modifiers: @public
, @private
, and @protected
. These tags work exactly like public
, private
, and protected
respectively work in TypeScript.
// @ts-check
class Foo {
constructor() {
/** @private */
this.stuff = 100;
}
printStuff() {
console.log(this.stuff);
}
}
new Foo().stuff;
// ~~~~~
// error! Property 'stuff' is private and only accessible within class 'Foo'.
@public
is always implied and can be left off, but means that a property can be reached from anywhere.
@private
means that a property can only be used within the containing class.
@protected
means that a property can only be used within the containing class, and all derived subclasses, but not on dissimilar instances of the containing class.
Next, we’ve also added the @readonly
modifier to ensure that a property is only ever written to during initialization.
// @ts-check
class Foo {
constructor() {
/** @readonly */
this.stuff = 100;
}
writeToStuff() {
this.stuff = 200;
// ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.
}
}
new Foo().stuff++;
// ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.
watchOptions
TypeScript has strived to provide reliable file-watching capabilities in --watch
mode and in editors for years. While it’s worked well for the most part, it turns out that file-watching in Node.js is hard, and its drawbacks can be reflected in our logic. The built-in APIs in Node.js are either CPU/energy-intensive and inaccurate (fs.watchFile
) or they’re wildly inconsistent across platforms (fs.watch
). Additionally, it’s practically impossible to determine which API will work better because it depends not only on the platform, but the file system on which a file resides.
This has been a struggle, because TypeScript needs to run on more platforms than just Node.js, and also strives to avoid dependencies to be entirely self-contained. This especially applies to dependencies on native Node.js modules.
Because every project might work better under different strategies, TypeScript 3.8 introduces a new watchOptions
field in tsconfig.json
and jsconfig.json
which allows users to tell the compiler/language service which watching strategies should be used to keep track of files and directories.
{
// Some typical compiler options
"compilerOptions": {
"target": "es2020",
"moduleResolution": "node",
// ...
},
// NEW: Options for file/directory watching
"watchOptions": {
// Use native file system events for files and directories
"watchFile": "useFsEvents",
"watchDirectory": "useFsEvents",
// Poll files for updates more frequently
// when they're updated a lot.
"fallbackPolling": "dynamicPriority"
}
}
watchOptions
contains 4 new options that can be configured:
watchFile
: the strategy for how individual files are watched. This can be set to
fixedPollingInterval
: Check every file for changes several times a second at a fixed interval.
priorityPollingInterval
: Check every file for changes several times a second, but use heuristics to check certain types of files less frequently than others.
dynamicPriorityPolling
: Use a dynamic queue where less-frequently modified files will be checked less often.
useFsEvents
(the default): Attempt to use the operating system/file system’s native events for file changes.
useFsEventsOnParentDirectory
: Attempt to use the operating system/file system’s native events to listen for changes on a file’s containing directories. This can use fewer file watchers, but might be less accurate.
watchDirectory
: the strategy for how entire directory trees are watched under systems that lack recursive file-watching functionality. This can be set to:
fixedPollingInterval
: Check every directory for changes several times a second at a fixed interval.
dynamicPriorityPolling
: Use a dynamic queue where less-frequently modified directories will be checked less often.
useFsEvents
(the default): Attempt to use the operating system/file system’s native events for directory changes.
fallbackPolling
: when using file system events, this option specifies the polling strategy that gets used when the system runs out of native file watchers and/or doesn’t support native file watchers. This can be set to
fixedPollingInterval
: (See above.)
priorityPollingInterval
: (See above.)
dynamicPriorityPolling
: (See above.)
synchronousWatchDirectory
: Disable deferred watching on directories. Deferred watching is useful when lots of file changes might occur at once (e.g. a change in node_modules
from running npm install
), but you might want to disable it with this flag for some less-common setups.
For more information on watchOptions
, head over to GitHub to see the pull request.
“Fast and Loose” Incremental Checking
TypeScript’s --watch
mode and --incremental
mode can help tighten the feedback loop for projects. Turning on --incremental
mode makes TypeScript keep track of which files can affect others, and on top of doing that, --watch
mode keeps the compiler process open and reuses as much information in memory as possible.
However, for much larger projects, even the dramatic gains in speed that these options afford us isn’t enough. For example, the Visual Studio Code team had built their own build tool around TypeScript called gulp-tsb
which would be less accurate in assessing which files needed to be rechecked/rebuilt in its watch mode, and as a result, could provide drastically low build times.
Sacrificing accuracy for build speed, for better or worse, is a tradeoff many are willing to make in the TypeScript/JavaScript world. Lots of users prioritize tightening their iteration time over addressing the errors up-front. As an example, it’s fairly common to build code regardless of the results of type-checking or linting.
TypeScript 3.8 introduces a new compiler option called assumeChangesOnlyAffectDirectDependencies
. When this option is enabled, TypeScript will avoid rechecking/rebuilding all truly possibly-affected files, and only recheck/rebuild files that have changed as well as files that directly import them.
For example, consider a file fileD.ts
that imports fileC.ts
that imports fileB.ts
that imports fileA.ts
as follows:
fileA.ts <- fileB.ts <- fileC.ts <- fileD.ts
In --watch
mode, a change in fileA.ts
would typically mean that TypeScript would need to at least re-check fileB.ts
, fileC.ts
, and fileD.ts
. Under assumeChangesOnlyAffectDirectDependencies
, a change in fileA.ts
means that only fileA.ts
and fileB.ts
need to be re-checked.
In a codebase like Visual Studio Code, this reduced rebuild times for changes in certain files from about 14 seconds to about 1 second. While we don’t necessarily recommend this option for all codebases, you might be interested if you have an extremely large codebase and are willing to defer full project errors until later (e.g. a dedicated build via a tsconfig.fullbuild.json
or in CI).
For more details, you can see the original pull request.
Breaking Changes
TypeScript 3.8 contains a few minor breaking changes that should be noted.
Stricter Assignability Checks to Unions with Index Signatures
Previously, excess properties were unchecked when assigning to unions where any type had an index signature – even if that excess property could never satisfy that index signature. In TypeScript 3.8, the type-checker is stricter, and only “exempts” properties from excess property checks if that property could plausibly satisfy an index signature.
const obj1: { [x: string]: number } | { a: number };
obj1 = { a: 5, c: 'abc' }
// ~
// Error!
// The type '{ [x: string]: number }' no longer exempts 'c'
// from excess property checks on '{ a: number }'.
let obj2: { [x: string]: number } | { [x: number]: number };
obj2 = { a: 'abc' };
// ~
// Error!
// The types '{ [x: string]: number }' and '{ [x: number]: number }' no longer exempts 'a'
// from excess property checks against '{ [x: number]: number }',
// and it *is* sort of an excess property because 'a' isn't a numeric property name.
// This one is more subtle.
object
in JSDoc is No Longer any
Under noImplicitAny
Historically, TypeScript’s support for checking JavaScript has been lax in certain ways in order to provide an approachable experience.
For example, users often used Object
in JSDoc to mean, “some object, I dunno what”, we’ve treated it as any
.
// @ts-check
/**
* @param thing {Object} some object, i dunno what
*/
function doSomething(thing) {
let x = thing.x;
let y = thing.y;
thing();
}
This is because treating it as TypeScript’s Object
type would end up in code reporting uninteresting errors, since the Object
type is an extremely vague type with few capabilities other than methods like toString
and valueOf
.
However, TypeScript does have a more useful type named object
(notice that lowercase o
). The object
type is more restrictive than Object
, in that it rejects all primitive types like string
, boolean
, and number
. Unfortunately, both Object
and object
were treated as any
in JSDoc.
Because object
can come in handy and is used significantly less than Object
in JSDoc, we’ve removed the special-case behavior in JavaScript files when using noImplicitAny
so that in JSDoc, the object
type really refers to the non-primitive object
type.
What’s Next?
Now that the beta is out, our team has been focusing largely on bug fixes and polish for what will eventually become TypeScript 3.8. As you can see on our current Iteration Plan, we’ll have one release candidate (a pre-release) in a couple of weeks, followed by a full release around mid-February. As editor features we’ve developed become more mature, we’ll also show off functionality like Call Hierarchy and the “convert to template string” refactoring.
If you’re able to give our beta a try, we would highly appreciate your feedback! So download it today, and happy hacking!
– Daniel Rosenwasser and the TypeScript Team
The post Announcing TypeScript 3.8 Beta appeared first on TypeScript.