Function returning a key with type never can be assigned to a typed Object
🔎 Search Terms
never, function, assignable, assign
🕗 Version & Regression Information
- This is the behavior in every version I tried (typescript@next and v5.4.5)
⏯ Playground Link
https://www.typescriptlang.org/play/?ts=5.5.0-dev.20240518#code/C4TwDgpgBAMghgIwPYCcIqgXigbygd1QGsAuKACgEosA+KAOwFcBbBdKAXwG4AoUSWHABeIeMjQZseQilIMIAN3bcePAGaN6AY2ABLJPSgBzCMDGp05AM7pdcADa6hEACYA1B4whkrwFLvojSjJ4EXMJXB4oKDRgRhRDACkAZQB5ADkAOjA4FBtrWwcnVw97L0peDlV7UyhmEAB1YnQQxAsUXnqm2XZsEzM2iXIAIkyx4YqoAHopqGAAC10rKCt5pEZ7FwYkYCg2KAd7JHxXABoD5bAUJEgUUChhmSJhqCW58Gh6RXYAh9DRQboYY8LrNFCZJ5ULjTWb4XT2exzebXfAHXYoTR6ZgQHhAA
💻 Code
type Laborer = { work: () => number };
type LazyLaborer = { work: never };
function getLaborer(serializedValue: string): LazyLaborer {
return JSON.parse(serializedValue);
}
let myWorker: Laborer;
myWorker = getLaborer("..."); // this should not be allowed, as property "work" is type never in "LazyLaborer"
myWorker.work(); // will throw at runtime
🙁 Actual behavior
The code compiles without an error.
🙂 Expected behavior
The code should not compile, as we assign work: never to a real object.
Additional information about the issue
My real use case is, that I want to check, if a type is serializable with JSON. Inspired by the blog mastering-type-safe-json-serialization-in-typescript, I wanted to write a small library like the following
export type JSONValue = JSONPrimitive | JSONObject | JSONArray;
export type JSONPrimitive = string | number | boolean | null
export type JSONObject = { [key: string]: JSONValue };
export type JSONArray = JSONValue[];
export type JSONSerializable<T> = unknown extends T ? never : {
[P in keyof T]:
T[P] extends JSONValue ? T[P] :
T[P] extends NotAssignableToJson ? never :
JSONSerializable<T[P]>;
};
type NotAssignableToJson = bigint
| symbol
| Function;
function stringify<T>(obj: JSONSerializable<T>): string {
return JSON.stringify(obj);
}
function parse<T>(serializedValue: string): JSONSerializable<T> {
return JSON.parse(serializedValue);
}
And the real code would look like the following:
let me: Laborer
// ...
// @ts-expect-error - this "works" already as expected
let serializedMe = stringify(me);
save(serializedMe);
// ...
serializedMe = load();
me = parse<Laborer>(serializedMe); // this should give me a compile error. This is useful to me, because I tried to de-/serialize a value, which wasn't serializable with JSON
me.work(); // will throw a runtime error. I would like to already catch this at compile time
never is the bottom type and is intentionally assignable to all other types. This is because the type is an empty set and has no inhabitants - by definition you can never have a value of type never. If the return type of getLaborer were accurate, the function could never return normally since it's impossible to construct a LazyLaborer.
To clarify since this is a common point of confusion: { work: never } doesn't mean an object that never has a work property. It means an object that always has a work property whose value is type never. You can't legally create such an object at runtime since no such value exists.
It means an object that always has a
workproperty whose value is typenever. You can't legally create such an object at runtime since no such value exists.
Or a property that throws upon access. It's fine to create a throwing getter.
Thanks for your fast replies :)
It means an object that always has a
workproperty whose value is typenever. You can't legally create such an object at runtime since no such value exists.
That is exactly my point. Yet, the example creates such a value of the impossible LazyLaborer type and assigns it to a variable of the valid Laborer type. There is no way, the example would not produce an (unintended) runtime exception. Therefore, I expect TypeScript to not compile.
I fail to see, that assigning an impossible type to a possible type can be a correct, type safe assignment. I feel like, I am missing something here?
The LazyLaborer example is of course stupid. But the ability to write a function parse<T>(serializedValue: string): JSONSerializable<T> and having a compile error when I try to generate a not possible type, would be quite valuable to me.
The problem with the example, and why it’s not an error as written, is because JSON.parse returns any. That’s a separate issue; the part where { work: never } is assignable to { work: LiterallyAnythingElse } is working as intended. Normally you wouldn’t be able to assign anything to LazyLaborer without a compile error, but any is the “shut up and leave me alone, TypeScript” type. :smile:
I fail to see, that assigning an impossible type to a possible type can be a correct, type safe assignment.
It’s a bit weird, but the mathematical explanation is that it’s the same as how the empty set is a subset of all other sets. More prosaically, if you have a function of type () => never, then that function by definition must always throw, so you can use it in place of () => T for any T. And since () => T is isomorphic to T…
Long story short: We can close this issue as works as intended, right?
Thanks a lot for your detailed answers! :)
Nevertheless, I think it would be useful, if assigning an impossible value to something would be a caught error by the compiler. I see no intentional use of writing a program, whose static types cannot reflect its runtime types. From my point of view, programming languages should be useful and help me to prevent errors: In the example me.work() will lead to a runtime error, which could have been caught by the compiler. I mean, let i = 0; ... i = 1; ... is mathematically questionable, yet surely useful in a for loop 😜
Do you think a feature request for that would be valid? If not, I will trust your judgement 👍
P.S.:
The problem with the example, and why it’s not an error as written, is because
JSON.parsereturnsany. That’s a separate issue;
I think you missed the implicit cast due to the type annotation in the function header 😉
P.P.S:
Just in case someone finds this helpful: Instead of mapping the not JSONable keys to never, now, I map the whole type to never like JSONSerializable<T> = T extends JSONValue ? T : never. This prevents false assignments.
You still seem to mistakingly believe that having a never typed property makes it an "impossible value", but that's not the case.
type T = { value: never }
const t: T = { get value(): never { throw new Error() } }
I think you missed the implicit cast due to the type annotation in the function header 😉
That's not a cast, it's just a normal return type annotation. If JSON.parse() returned something other than any (like unknown), then the program would have had an error. The root of the issue in that particular example is that an unsound assignment to { work: never } isn't caught because the assignment source is any.
The bottom type means you can never access this value without throwing. As long as that invariant is maintained, then any assignment from never to any other type is sound, which is why the type system allows it.
Just in case someone finds this helpful: Instead of mapping the not JSONable keys to never, now, I map the whole type to never like JSONSerializable<T> = T extends JSONValue ? T : never. This prevents false assignments.
The ideal solution for this use case would be https://github.com/microsoft/TypeScript/issues/23689, but I don't know if we'll ever get it ☹️
awesome, I learned something. I agree that it works as intended and https://github.com/microsoft/TypeScript/issues/23689 is the feature I am looking for. Thanks a lot for taking the time!