Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Function returning a key with type never can be assigned to a typed Object #58565

Closed
shaman-apprentice opened this issue May 18, 2024 · 11 comments

Comments

@shaman-apprentice
Copy link

shaman-apprentice commented May 18, 2024

πŸ”Ž 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
@fatcerberus
Copy link

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.

@fatcerberus
Copy link

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.

@MartinJohns
Copy link
Contributor

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.

Or a property that throws upon access. It's fine to create a throwing getter.

@shaman-apprentice
Copy link
Author

Thanks for your fast replies :)

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.

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.

@fatcerberus
Copy link

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. πŸ˜„

@fatcerberus
Copy link

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…

@shaman-apprentice
Copy link
Author

shaman-apprentice commented May 19, 2024

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.parse returns any. 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.

@MartinJohns
Copy link
Contributor

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() } }

@fatcerberus
Copy link

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.

@fatcerberus
Copy link

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 extends JSONValue ? T : never. This prevents false assignments.

The ideal solution for this use case would be #23689, but I don't know if we'll ever get it ☹️

@shaman-apprentice
Copy link
Author

shaman-apprentice commented May 19, 2024

awesome, I learned something. I agree that it works as intended and #23689 is the feature I am looking for. Thanks a lot for taking the time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants