TypeScript 5.4
Preserved Narrowing in Closures Following Last Assignments
TypeScript can usually figure out a more specific type for a variable based on checks that you might perform.This process is called narrowing.
tsfunctionuppercaseStrings(x:string |number) {if (typeofx ==="string") {// TypeScript knows 'x' is a 'string' here.returnx.toUpperCase();}}
One common pain point was that these narrowed types weren’t always preserved within function closures.
tsfunctiongetUrls(url:string |URL,names:string[]) {if (typeofurl ==="string") {url =newURL(url);}returnnames.map(name=> {url.searchParams.set("name",name)// ~~~~~~~~~~~~// error!// Property 'searchParams' does not exist on type 'string | URL'.returnurl.toString();});}
Here, TypeScript decided that it wasn’t “safe” to assume thaturl wasactually aURL object in our callback function because it was mutated elsewhere;however, in this instance, that arrow function isalways created after that assignment tourl, and it’s also thelast assignment tourl.
TypeScript 5.4 takes advantage of this to make narrowing a little smarter.When parameters andlet variables are used in non-hoisted functions, the type-checker will look for a last assignment point.If one is found, TypeScript can safely narrow from outside the containing function.What that means is the above example just works now.
Note that narrowing analysis doesn’t kick in if the variable is assigned anywhere in a nested function.This is because there’s no way to know for sure whether the function will be called later.
tsfunctionprintValueLater(value:string |undefined) {if (value ===undefined) {value ="missing!";}setTimeout(()=> {// Modifying 'value', even in a way that shouldn't affect// its type, will invalidate type refinements in closures.value =value;},500);setTimeout(()=> {console.log(value.toUpperCase());// ~~~~~// error! 'value' is possibly 'undefined'.},1000);}
This should make lots of typical JavaScript code easier to express.You canread more about the change on GitHub.
TheNoInfer Utility Type
When calling generic functions, TypeScript is able to infer type arguments from whatever you pass in.
tsfunctiondoSomething<T>(arg:T) {// ...}// We can explicitly say that 'T' should be 'string'.doSomething<string>("hello!");// We can also just let the type of 'T' get inferred.doSomething("hello!");
One challenge, however, is that it is not always clear what the “best” type is to infer.This might lead to TypeScript rejecting valid calls, accepting questionable calls, or just reporting worse error messages when it catches a bug.
For example, let’s imagine acreateStreetLight function that takes a list of color names, along with an optional default color.
tsfunctioncreateStreetLight<Cextendsstring>(colors:C[],defaultColor?:C) {// ...}createStreetLight(["red","yellow","green"],"red");
What happens when we pass in adefaultColor that wasn’t in the originalcolors array?In this function,colors is supposed to be the “source of truth” and describe what can be passed todefaultColor.
ts// Oops! This is undesirable, but is allowed!createStreetLight(["red","yellow","green"],"blue");
In this call, type inference decided that"blue" was just as valid of a type as"red" or"yellow" or"green".So instead of rejecting the call, TypeScript infers the type ofC as"red" | "yellow" | "green" | "blue".You might say that inference just blue up in our faces!
One way people currently deal with this is to add a separate type parameter that’s bounded by the existing type parameter.
tsfunctioncreateStreetLight<Cextendsstring,DextendsC>(colors:C[],defaultColor?:D) {}createStreetLight(["red","yellow","green"],"blue");// ~~~~~~// error!// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
This works, but is a little bit awkward becauseD probably won’t be used anywhere else in the signature forcreateStreetLight.While not badin this case, using a type parameter only once in a signature is often a code smell.
That’s why TypeScript 5.4 introduces a newNoInfer<T> utility type.Surrounding a type inNoInfer<...> gives a signal to TypeScript not to dig in and match against the inner types to find candidates for type inference.
UsingNoInfer, we can rewritecreateStreetLight as something like this:
tsfunctioncreateStreetLight<Cextendsstring>(colors:C[],defaultColor?:NoInfer<C>) {// ...}createStreetLight(["red","yellow","green"],"blue");// ~~~~~~// error!// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.
Excluding the type ofdefaultColor from being explored for inference means that"blue" never ends up as an inference candidate, and the type-checker can reject it.
You can see the specific changes inthe implementing pull request, along withthe initial implementation provided thanks toMateusz Burzyński!
Object.groupBy andMap.groupBy
TypeScript 5.4 adds declarations for JavaScript’s newObject.groupBy andMap.groupBy static methods.
Object.groupBy takes an iterable, and a function that decides which “group” each element should be placed in.The function needs to make a “key” for each distinct group, andObject.groupBy uses that key to make an object where every key maps to an array with the original element in it.
So the following #"even":"odd";
is basically equivalent to writing this:
jsconstmyObj = {even: [0,2,4],odd: [1,3,5],};
Map.groupBy is similar, but produces aMap instead of a plain object.This might be more desirable if you need the guarantees ofMaps, you’re dealing with APIs that expectMaps, or you need to use any kind of key for grouping - not just keys that can be used as property names in JavaScript.
jsconstmyObj =Map.groupBy(array, (num,index)=> {returnnum %2 ===0 ?"even" :"odd";});
and just as before, you could have createdmyObj in an equivalent way:
jsconstmyObj =newMap();myObj.set("even", [0,2,4]);myObj.set("odd", [1,3,5]);
Note that in the above example ofObject.groupBy, the object produced uses all optional properties.
tsinterfaceEvenOdds {even?:number[];odd?:number[];}constmyObj:EvenOdds =Object.groupBy(...);myObj.even;// ~~~~// Error to access this under 'strictNullChecks'.
This is because there’s no way to guarantee in a general way thatall the keys were produced bygroupBy.
Note also that these methods are only accessible by configuring yourtarget toesnext or adjusting yourlib settings.We expect they will eventually be available under a stablees2024 target.
We’d like to extend a thanks toKevin Gibbons foradding the declarations to thesegroupBy methods.
Support forrequire() calls in--moduleResolution bundler and--module preserve
TypeScript has amoduleResolution option calledbundler that is meant to model the way modern bundlers figure out which file an import path refers to.One of the limitations of the option is that it had to be paired with--module esnext, making it impossible to use theimport ... = require(...) syntax.
ts// previously erroredimportmyModule =require("module/path");
That might not seem like a big deal if you’re planning on just writing standard ECMAScriptimports, but there’s a difference when using a package withconditional exports.
In TypeScript 5.4,require() can now be used when setting themodule setting to a new option calledpreserve.
Between--module preserve and--moduleResolution bundler, the two more accurately model what bundlers and runtimes like Bun will allow, and how they’ll perform module lookups.In fact, when using--module preserve, thebundler option will be implicitly set for--moduleResolution (along with--esModuleInterop and--resolveJsonModule)
json{"compilerOptions": {"module":"preserve",// ^ also implies:// "moduleResolution": "bundler",// "esModuleInterop": true,// "resolveJsonModule": true,// ...}}
Under--module preserve, an ECMAScriptimport will always be emitted as-is, andimport ... = require(...) will be emitted as arequire() call (though in practice you may not even use TypeScript for emit, since it’s likely you’ll be using a bundler for your code).This holds true regardless of the file extension of the containing file.So the output of this code:
tsimport*asfoofrom"some-package/foo";importbar =require("some-package/bar");
should look something like this:
jsimport*asfoofrom"some-package/foo";varbar =require("some-package/bar");
What this also means is that the syntax you choose directs howconditional exports are matched.So in the above example, if thepackage.json ofsome-package looks like this:
json{"name":"some-package","version":"0.0.1","exports": {"./foo": {"import":"./esm/foo-from-import.mjs","require":"./cjs/foo-from-require.cjs"},"./bar": {"import":"./esm/bar-from-import.mjs","require":"./cjs/bar-from-require.cjs"}}}
TypeScript will resolve these paths to[...]/some-package/esm/foo-from-import.mjs and[...]/some-package/cjs/bar-from-require.cjs.
For more information, you canread up on these new settings here.
Checked Import Attributes and Assertions
Import attributes and assertions are now checked against the globalImportAttributes type.This means that runtimes can now more accurately describe the import attributes
ts// In some global file.interfaceImportAttributes {type:"json";}// In some other moduleimport*asnsfrom"foo"with {type: "not-json" };// ~~~~~~~~~~// error!//// Type '{ type: "not-json"; }' is not assignable to type 'ImportAttributes'.// Types of property 'type' are incompatible.// Type '"not-json"' is not assignable to type '"json"'.
This change was provided thanks toOleksandr Tarasiuk.
Quick Fix for Adding Missing Parameters
TypeScript now has a quick fix to add a new parameter to functions that are called with too many arguments.


This can be useful when threading a new argument through several existing functions, which can be cumbersome today.
This quick fix was provided courtsey ofOleksandr Tarasiuk.
Upcoming Changes from TypeScript 5.0 Deprecations
TypeScript 5.0 deprecated the following options and behaviors:
charsettarget: ES3importsNotUsedAsValuesnoImplicitUseStrictnoStrictGenericCheckskeyofStringsOnlysuppressExcessPropertyErrorssuppressImplicitAnyIndexErrorsoutpreserveValueImportsprependin project references- implicitly OS-specific
newLine
To continue using them, developers using TypeScript 5.0 and other more recent versions have had to specify a new option calledignoreDeprecations with the value"5.0".
However, TypScript 5.4 will be the last version in which these will continue to function as normal.By TypeScript 5.5 (likely June 2024), these will become hard errors, and code using them will need to be migrated away.
For more information, you canread up on this plan on GitHub, which contains suggestions in how to best adapt your codebase.
Notable Behavioral Changes
This section highlights a set of noteworthy changes that should be acknowledged and understood as part of any upgrade.Sometimes it will highlight deprecations, removals, and new restrictions.It can also contain bug fixes that are functionally improvements, but which can also affect an existing build by introducing new errors.
lib.d.ts Changes
Types generated for the DOM may have an impact on type-checking your codebase.For more information,see the DOM updates for TypeScript 5.4.
More Accurate Conditional Type Constraints
The following code no longer allows the second variable declaration in the functionfoo.
tstypeIsArray<T> =Textendsany[] ?true :false;functionfoo<Uextendsobject>(x:IsArray<U>) {letfirst:true =x;// Errorletsecond:false =x;// Error, but previously wasn't}
Previously, when TypeScript checked the initializer forsecond, it needed to determine whetherIsArray<U> was assignable to the unit typefalse.WhileIsArray<U> isn’t compatible any obvious way, TypeScript looks at theconstraint of that type as well.In a conditional type likeT extends Foo ? TrueBranch : FalseBranch, whereT is generic, the type system would look at the constraint ofT, substitute it in forT itself, and decide on either the true or false branch.
But this behavior was inaccurate because it was overly eager.Even if the constraint ofT isn’t assignable toFoo, that doesn’t mean that it won’t be instantiated with something that is.And so the more correct behavior is to produce a union type for the constraint of the conditional type in cases where it can’t be proven thatTnever oralways extendsFoo.
TypeScript 5.4 adopts this more accurate behavior.What this means in practice is that you may begin to find that some conditional type instances are no longer compatible with their branches.
You can read about the specific changes here.
More Aggressive Reduction of Intersections Between Type Variables and Primitive Types
TypeScript now reduces intersections with type variables and primitives more aggressively, depending on how the type variable’s constraint overlaps with those primitives.
tsdeclarefunctionintersect<T,U>(x:T,y:U):T &U;functionfoo<Textends"abc" |"def">(x:T,str:string,num:number) {// Was 'T & string', now is just 'T'leta =intersect(x,str);// Was 'T & number', now is just 'never'letb =intersect(x,num)// Was '(T & "abc") | (T & "def")', now is just 'T'letc =Math.random() <0.5 ?intersect(x,"abc") :intersect(x,"def");}
For more information,see the change here.
Improved Checking Against Template Strings with Interpolations
TypeScript now more accurately checks whether or not strings are assignable to the placeholder slots of a template string type.
tsfunctiona<Textends {id:string}>() {letx:`-${keyofT&string}`;// Used to error, now doesn't.x ="-id";}
This behavior is more desirable, but may cause breaks in code using constructs like conditional types, where these rule changes are easy to witness.
See this change for more details.
Errors When Type-Only Imports Conflict with Local Values
Previously, TypeScript would permit the following code underisolatedModules if the import toSomething only referred to a type.
tsimport {Something }from"./some/path";letSomething =123;
However, it’s not safe for single-file compilers to assume whether it’s “safe” to drop theimport, even if the code is guaranteed to fail at runtime.In TypeScript 5.4, this code will trigger an error like the following:
Import 'Something' conflicts with local value, so must be declared with a type-only import when 'isolatedModules' is enabled.
The fix should be to either make a local rename, or, as the error states, add thetype modifier to the import:
tsimporttype {Something }from"./some/path";// orimport {typeSomething }from"./some/path";
See more information on the change itself.
New Enum Assignability Restrictions
When two enums have the same declared names and enum member names, they were previously always considered compatible;however, when the values were known, TypeScript would silently allow them to have differing values.
TypeScript 5.4 tightens this restriction by requiring the values to be identical when they are known.
tsnamespaceFirst {exportenumSomeEnum {A =0,B =1,}}namespaceSecond {exportenumSomeEnum {A =0,B =2,}}functionfoo(x:First.SomeEnum,y:Second.SomeEnum) {// Both used to be compatible - no longer the case,// TypeScript errors with something like://// Each declaration of 'SomeEnum.B' differs in its value, where '1' was expected but '2' was given.x =y;y =x;}
Additionally, there are new restrictions for when one of the enum members does not have a statically known value.In these cases, the other enum must at least be implicitly numeric (e.g. it has no statically resolved initializer), or it is explicitly numeric (meaning TypeScript could resolve the value to something numeric).Practically speaking, what this means is that string enum members are only ever compatible with other string enums of the same value.
tsnamespaceFirst {exportdeclareenumSomeEnum {A,B,}}namespaceSecond {exportdeclareenumSomeEnum {A,B ="some known string",}}functionfoo(x:First.SomeEnum,y:Second.SomeEnum) {// Both used to be compatible - no longer the case,// TypeScript errors with something like://// One value of 'SomeEnum.B' is the string '"some known string"', and the other is assumed to be an unknown numeric value.x =y;y =x;}
For more information,see the pull request that introduced this change.
Name Restrictions on Enum Members
TypeScript no longer allows enum members to use the namesInfinity,-Infinity, orNaN.
ts// Errors on all of these://// An enum member cannot have a numeric name.enumE {Infinity =0,"-Infinity" =1,NaN =2,}
Better Mapped Type Preservation Over Tuples withany Rest Elements
Previously, applying a mapped type withany into a tuple would create anany element type.This is undesirable and is now fixed.
tsPromise.all(["", ...([]asany)]).then((result)=> {consthead =result[0];// 5.3: any, 5.4: stringconsttail =result.slice(1);// 5.3 any, 5.4: any[]});
For more information, seethe fix along withthe follow-on discussion around behavioral changes andfurther tweaks.
Emit Changes
While not a breaking change per se, developers may have implicitly taken dependencies on TypeScript’s JavaScript or declaration emit outputs.The following are notable changes.