I loathe type assertions in TypeScript. Every as is a spot where I am telling
the compiler “trust me”, and every as unknown as is me telling it to stop
thinking altogether. So when I found one in an adapter function I was working
on, I wanted to get rid of it. In the process, I learned an interesting fact
about how unknown and Object.assign behave together.
The Adapter
The setup was roughly this: a library exposes a function that takes a client,
and that client must have a request function resolving to a Response with a
typed json method. On my side, I already had a function that does the request
and returns the parsed data directly. I just needed to adapt one to the other.
Here is a minimal version of it:
type TypedResponse<T> = Omit<Response, 'json'> & {json: () => Promise<T>}
type Client = {
request: (query: string) => Promise<TypedResponse<{data?: unknown}>>
}
type FetchData = (query: string) => Promise<unknown>
The library only ever calls json() on the response, so my first version just returned an object with that method:
export function toClient(fetchData: FetchData): Client {
return {
request: async (query) => {
const data = await fetchData(query)
return {json: async () => ({data})}
},
}
}
TypeScript, correctly, doesn’t accept it:
Type '{ json: () => Promise<{ data: unknown; }>; }' is not assignable to type
'TypedResponse<{ data?: unknown; }>'.
Type '{ json: () => Promise<{ data: unknown; }>; }' is missing the following
properties from type 'Omit<Response, "json">': body, bodyUsed, arrayBuffer,
blob, and 11 more.
The code that was there before me solved it by casting the whole client with as unknown as Client. It works, but it also means that if the library changes the Client type, the compiler won’t say a word.
The Fix
What I wanted was a real Response with a different json method. That’s exactly what Object.assign gives you:
export function toClient(fetchData: FetchData): Client {
return {
request: async (query) => {
const data = await fetchData(query)
return Object.assign(new Response(), {json: async () => ({data})})
},
}
}
No assertion, and it type-checks. To understand why, I wrote a smaller example.
Why It Works
Here is the reduced version (playground link):
type A1 = {a: unknown, b: number}
type A2 = {a: () => number}
const target: A1 = {a: () => 5, b: 2}
const source: A2 = {a: () => 5}
const myValue = Object.assign(target, source)
console.log(myValue)
If you hover over myValue, you’ll see its type is A1 & A2, and calling myValue.a() compiles just fine. However, target.a() doesn’t:
error TS18046: 'target.a' is of type 'unknown'.
That’s a bit surprising at first, because myValue and target are the exact same object. Object.assign mutates the target and returns it, so myValue === target is true at runtime. TypeScript just knows more about one name than the other.
The answer is in the type declaration of Object.assign in lib.es2015.core.d.ts:
assign<T extends {}, U>(target: T, source: U): T & U;
The return type is an intersection of the target and the source. When you
intersect two object types, the properties with the same name are intersected
too, so a becomes unknown & (() => number). Since unknown is the top type
(every type is assignable to it), intersecting anything with unknown gives
you back that same thing, the same way that x && true is just x. So a is
simply () => number.
In this case, the type matches what happens at runtime. According to the MDN documentation, Object.assign copies all enumerable own properties from the source to the target, and properties in the target are overwritten by properties in the source with the same key. So a really is the function from source after the call.
Back to the adapter: Object.assign(new Response(), {json}) has the type Response & {json: () => Promise<{data: unknown}>}. Everything that Omit<Response, 'json'> asks for comes from the Response side, and json comes from the object I passed in, so it is assignable to TypedResponse<{data?: unknown}>. Callers still get a typed json() that resolves to {data?: unknown}, because the Omit in TypedResponse drops the original json(): Promise<any> declaration. And at runtime it really is a Response, so ok, status, and headers all exist.
Why Not Spread?
A common solution to “copy this object but replace one field” is usually the
spread syntax. With the standard lib.dom.d.ts types, TypeScript even accepts
it here:
const spread: TypedResponse<{data: number}> = {
...new Response(),
json: async () => ({data: 1}),
}
But this is broken at runtime. Response keeps its properties as getters on the prototype, not as own properties, and spread only copies own enumerable properties. Running it with both Node and Bun:
console.log(spread.ok, spread.status, spread instanceof Response)
// undefined undefined false
const assigned = Object.assign(new Response(), {json: async () => ({data: 1})})
console.log(assigned.ok, assigned.status, assigned instanceof Response)
// true 200 true
TypeScript can’t catch it because Response is declared as an interface in lib.dom.d.ts, so from the type system’s point of view spreading it copies everything. Interestingly, Bun’s own types (@types/bun) reject the spread version, complaining that clone is missing, but that’s more luck than a real model of what spread does at runtime. Object.assign keeps the original object, prototype included, so it’s the right tool here.
Where It Breaks
The T & U signature is a nice approximation, but it isn’t really what Object.assign does. The source overwrites the target, it doesn’t combine with it. When the types are compatible, like unknown and a function, you don’t notice the difference. When they are not, things get weird:
const x = Object.assign({a: 1}, {a: "hello"})
const bad: never = x.a // compiles
console.log(typeof x.a) // "string"
Here a becomes number & string, which is never. TypeScript is happy to let me assign it to a never variable, while at runtime it’s just the string "hello".
Wrapping Up
So, to summarize:
Object.assign(target, source)is typed asT & U.- Intersecting with
unknownis a no-op, so anunknownfield can be “upgraded” by the source type. - That makes
Object.assign(new Response(), {json})a way to build a realResponsewith a customjsonwithout any type assertion. - Spread type-checks for the same case but loses everything that lives on the prototype.
- When property types conflict, the intersection can collapse to
neverwhile the runtime value is perfectly normal.
I am not a TypeScript compiler expert, so if I got something wrong in my reasoning, please let me know. How do you usually get rid of type assertions in your code?