TypeScript 4.5: The Awaited Type and Promise unwrapping
TypeScript 4.5 dropped on November 17th, and honestly? The feature I’m most excited about is Awaited<T>. Not exactly headline material — it’s a utility type, and utility types don’t exactly trend on Hacker News. But this one fixes a class of problems I’ve been wrestling with for years, and the solution is surprisingly elegant.
The problem Awaited actually solves #
Ever written a generic function that takes a Promise<T> and tried to extract what it actually resolves to? Yeah. Before 4.5, TypeScript had no built-in way to recursively unwrap nested promises — and believe me, I tried.
// What type is result, really?
type Result = Promise<Promise<Promise<string>>>;
// Before 4.5, you'd hack something like this:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type R1 = UnwrapPromise<Result>; // Promise<Promise<string>> -- one level, then it gives up
You could make it recursive, sure. But you’d slam into the type instantiation depth limit fast. And the recursive version had edge cases around any, never, and union types that were — let’s be honest — a pain to get right.
Awaited just handles it:
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // number
type C = Awaited<boolean | Promise<number>>; // boolean | number
Clean. Recursive. Handles unions. Handles any and never without exploding. This is what I wanted three years ago.
Why Promise.all actually needed this #
The real motivation behind Awaited wasn’t developer convenience — though I’ll take it. It was fixing Promise.all and Promise.race inference, which had been subtly broken forever.
Check this out:
async function fetchUser(): Promise<User> { /* ... */ }
async function fetchPosts(): Promise<Post[]> { /* ... */ }
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
// user: User, posts: Post[] -- works fine
Simple case, no surprises. But things got weird with nested promises or conditional types:
function getData<T>(val: T): Promise<T> {
return Promise.resolve(val);
}
// Before 4.5, this could produce Promise<Promise<string>> in some edge cases
const result = await Promise.all([getData(Promise.resolve("hello"))]);The problem? Promise.all’s type signature used a simple T extends PromiseLike<infer U> ? U : T pattern. One level of unwrapping. Meanwhile, the actual await mechanism recursively unwraps — but the types didn’t match that behavior.
TypeScript 4.5 rewrites Promise.all, Promise.race, Promise.allSettled, and Promise.any to use Awaited<T>. The types now match what actually happens at runtime. That’s the fix.
The implementation is worth a look #
Here’s the actual definition from lib/es5.d.ts:
type Awaited<T> =
T extends null | undefined ? T :
T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ?
F extends ((value: infer V, ...args: infer _) => any) ?
Awaited<V> :
never :
T;A few things jump out.
First — it doesn’t check for Promise directly. It looks for any object with a then method, so it works with any thenable, not just native Promises. This matches how await actually works in the spec.
Second, the recursion is in Awaited<V>. Unwrap one layer, get V, and if V is itself a thenable, Awaited goes again. That’s how Awaited<Promise<Promise<string>>> collapses all the way down to string.
Third, that null | undefined check at the top. Without it, Awaited<null> would try to check if null has a then method and produce never. The early return keeps the input type intact.
This works now because TypeScript 4.5 also ships tail-recursive conditional type evaluation. Previous versions would hit a depth limit on recursive conditionals like this. The compiler now recognizes tail-position recursion and evaluates it iteratively, supporting much deeper instantiation chains.
Top-level await finally gets a stable target #
TypeScript 4.5 adds es2022 as a module target, and with it, top-level await is no longer stuck behind esnext. This matters if you ship libraries.
// tsconfig.json
{
"compilerOptions": {
"module": "es2022",
"target": "es2022"
}
}Previously, top-level await meant "module": "esnext". But esnext is a moving target — it means “whatever’s newest right now.” Fine for apps. For libraries that need stable compilation targets? Problematic. Shipping a library compiled to esnext meant your consumers’ TypeScript version had to be recent enough to understand whatever esnext meant that week.
With es2022, top-level await has a stable, versioned target. Library authors can compile to it knowing the semantics won’t shift underneath them.
// Top-level await in an ES module
const config = await loadConfig();
const db = await connectDatabase(config);
export { db };One catch: top-level await only works in ES modules. If you’re still on CommonJS — and plenty of Node.js code is — this doesn’t help you. The module system distinction matters here; await at the top level is inherently async, and CommonJS’s synchronous require() can’t handle that.
Import assertions #
Another feature worth mentioning: import assertions. They let you specify metadata about an import, usually the module type:
import data from './config.json' assert { type: 'json' };This tells the runtime (and TypeScript) to treat the module as JSON. Without the assertion, importing JSON files requires a bundler plugin, a custom loader, or TypeScript’s resolveJsonModule with CommonJS.
The syntax is a stage 3 TC39 proposal. TypeScript 4.5 supports it syntactically and type-checks the imported module, but runtime behavior depends on your engine. Node.js 17.1+ supports JSON import assertions behind a flag; Deno supports them natively.
I’m cautiously optimistic here. The assertion syntax itself might change — there’s been talk about switching from assert to with — but the concept of annotating imports with metadata is solid. It’s useful for environments where you need to import non-JavaScript resources in a standards-compliant way.
Tail-recursive conditional types (the quiet game-changer) #
I mentioned tail-recursive evaluation earlier, but it deserves its own section. Before 4.5, deeply recursive conditional types would just error out:
type TrimLeft<T extends string> =
T extends ` ${infer Rest}` ? TrimLeft<Rest> : T;
// Before 4.5: "Type instantiation is excessively deep and possibly infinite"
type Trimmed = TrimLeft<" hello">;
// After 4.5: "hello" -- works fine
The compiler now detects when a conditional type’s true branch is in tail position — meaning the result of the conditional is directly the recursive call, no additional type operations wrapping it — and uses iterative evaluation instead of building a deep call stack.
This unlocks a category of type-level programming that was previously impractical. String manipulation types, deeply nested data structures, parser combinators at the type level — all of these benefit from the increased depth limit.
The constraint: it must be tail-recursive. If you wrap the recursive call in another type constructor, like [TrimLeft<Rest>], the compiler can’t apply the optimization and you’ll still hit limits. But for common patterns, it just works.
What this release actually signals #
TypeScript’s recent releases have focused more on type system expressiveness than new syntax. Awaited, tail-recursive conditionals, and the ongoing work on template literal types point in a clear direction: make the type system powerful enough that you rarely need as casts or @ts-ignore comments.
I think that’s the right call. The runtime language is stable — it’s JavaScript. TypeScript’s value is in the type layer, and the more that layer can express, the fewer escape hatches developers need. 4.5 is a solid step in that direction — particularly Awaited, which closes a gap that’s existed since async/await landed in TypeScript 2.1 back in 2016.
If you haven’t upgraded yet, the migration from 4.4 is smooth. No breaking changes I’ve hit in production code. Just better types.