Skip to content
This repository has been archived by the owner on Jan 29, 2024. It is now read-only.

Excess property checks for destructured arguments #359

Open
mindplay-dk opened this issue Mar 15, 2022 · 8 comments
Open

Excess property checks for destructured arguments #359

mindplay-dk opened this issue Mar 15, 2022 · 8 comments

Comments

@mindplay-dk
Copy link

I've struck up a longer discussion about type-checking of destructured arguments in a Typescript issue here and I was wondering if I could get your opinion on this matter.

Given this example:

type User = {
  id: number;
  name: string;
  email: string;
}

function stringifyUser({ name, email }: User): string {
  return `${name} (${email})`
}

Would it be reasonable to infer the argument type as Pick<User, "name" | "email">?

I mean, the underlying JS does not require an object with an id property, so why should this be required by the type-checker?

Hegel tends to be about accuracy, and I had actually hoped to find it already working like this.

But as they pointed out in the thread, only 4 people have ever asked for this feature, so maybe it's just not something anybody notice, cares or thinks about. From my perspective, pretty simple: I want functions with as few dependencies as are actually required.

And sure, I could type out Pick types all day, but why wouldn't the type-checker just check the types that are actually relevant to the function, instead of demanding properties that the function can't actually access?

That's how it works in JS, where destructuring arguments is the closest thing we have to a type-hint.

What do you think? 🤔

@thecotne
Copy link
Contributor

you are asking for User type and you are getting User and fact that you are not using all User props is internal logic and can change in the future and should not cause breakage on the consumer side of that function

if you want to make that internal logic same as external interface in hegel you can write this

function stringifyUser({ name, email }): string {
  return `${name} (${email})`
}

and inferred type will be what you wanted

<_a: { 'email': string, 'name': string, ... }>(_a) => string

you don't need to annotate anything type is derived from usage


this can be solved with AutoPick<T> type like so

type User = {
  id: number;
  name: string;
  email: string;
}

function stringifyUser({ name, email }: AutoPick<User>): string {
  return `${name} (${email})`
}

so it's same as Pick<User, "name" | "email"> type but second argument is derived from destructuring logic

i don't see this implemented in typescript or hegel anytime soon but it can be done

@mindplay-dk
Copy link
Author

mindplay-dk commented Mar 15, 2022

you are asking for User type and you are getting User and fact that you are not using all User props is internal logic and can change in the future and should not cause breakage on the consumer side of that function

Is it "internal logic" though?

I mean, it's part of the function declaration - it's the closest thing we have to a type-hint in JS, in practice saying, "the argument is an object with these properties".

I think it's a matter of description. The way I see it, the fact that an object is required, and the fact that the object must have certain properties, that's not just internal logic - it's part of the function's public interface. These are declarations with static meaning.

It's a close analog to named arguments, as well as (effectively) a shallow run-time type-check.

Adding new properties to a destructured argument is not that different from adding new arguments to a function - unless they're optional, calling the function without those arguments should be an error.

And vice versa, calling the function with extra arguments is permitted, just as calling the function with extra properties to destructured arguments is permitted.

But not required.

Why would it be? 🤔

@mindplay-dk
Copy link
Author

Let me reverse the question:

Where do you see a case for functions that demand properties they can't access or use? 🤷‍♂️

@thecotne
Copy link
Contributor

i understand where confusion comes from but destructuring is not part of function interface

it's just a suger syntax

// original
function stringifyUser({ name, email }) {
  return `${name} (${email})`
}
// desugering pass 1
function stringifyUser(obj) {
  const { name, email } = obj
  return `${name} (${email})`
}
// desugering pass 2
function stringifyUser(obj) {
  const name = obj.name
  const email = obj.email
  return `${name} (${email})`
}

this is what js engines do on AST level before executing that function

so it is functions internal logic and has nothing to do with types

@thecotne
Copy link
Contributor

Let me reverse the question:

Where do you see a case for functions that demand properties they can't access or use? 🤷‍♂️

btw they can access it

type User = {
  id: number;
  name: string;
  email: string;
}

function stringifyUser({ name, email }: User): string {
  return `${name} (${email}) - ${arguments[0].id}`
}

@mindplay-dk
Copy link
Author

i understand where confusion comes from but destructuring is not part of function interface

it's just a suger syntax

I promise, I'm not confused. 😄

What you explained is just language implementation details. How the language or some transpilers implement this feature internally is rather moot. I'm talking about what the language feature means to programs and programmers in a practical sense.

If you asked for { name, email }, you are asking for an object - both at run-time and statically, you've declared a function that accepts only objects with those properties. That's what this feature means in practice, to programmers.

btw they can access it

I also noted that in my original post:

This being JavaScript, you can of course access the object using arguments[0] - so the type of this object could in fact matter.

However, the type of arguments[0] is always any, and so, in that case, there's no type safety either way; if the rest of the type is important, you probably aren't (and likely shouldn't be) destructuring the argument in the first place.

So this seems unlikely to affect anything other than things like currying and higher-order functions, where you wouldn't be destructing anyway - and so, it seems unlikely this will cause any adverse effects in practice.

Can you think of a practical example where this would be problematic?

Let's get to the bottom of this. 😄

If a function declaration explicitly declares a dependency on a subset of properties of an object:

When, how or why is it useful to get a resulting function type that validates statically for the presence of properties that the function does not ask for or expect at run-time?

@thecotne
Copy link
Contributor

If you asked for { name, email }, you are asking for an object - both at run-time and statically, you've declared a function that accepts only objects with those properties. That's what this feature means in practice, to programmers.

you are not asking for { name, email } that part is internal to the function

you are asking for User and that is what any type checker is going to check for

if you want usage based interface on functions hegel does do that but in that case you need to remove annotation for User type

Can you think of a practical example where this would be problematic?

i have a function that's asking for User object and current implementation does not use email prop but i want consumers of this function to pass full User objects so when i change how my function works and start using email prop consumers don't need to update how they use this function

@mindplay-dk
Copy link
Author

you are not asking for { name, email } that part is internal to the function

That's a matter of interpretation.

If you have a function that accepts { a, b } today, and tomorrow it requires { a, b, c }, that's a breaking change to the emitted JS code - regardless of how you type-hinted it in Hegel.

So the way I see it, it's not internal to the function - it's part of it's public signature.

I mean, if you wanted to be really pedantic, you could argue that the whole argument list is internal to the function, right? Technically, a function (a, b) can be called with just (a) or with (a, b, c) etc. - JavaScript won't complain, arguments is just a list of the arguments that were given.

you are asking for User and that is what any type checker is going to check for

In a nominally-typed language, yes - but in a structurally-typed language?

What you really asked for is "object matching this shape" - and then explicitly selected certain specific properties from that shape. If we have these facts, why would we type-check properties you didn't ask for? Unless you also asked for ...rest, the reality is a function that accepts, and works correctly for, any object with a name and email property.

Can you think of a practical example where this would be problematic?

i have a function that's asking for User object and current implementation does not use email prop but i want consumers of this function to pass full User objects so when i change how my function works and start using email prop consumers don't need to update how they use this function

That sounds more like an imagined future problem than a practical problem.

If you anticipate a future need for a full User instance, you probably shouldn't destructure - again, since type-hints are elided, when you destructure, the emitted JS is code that does not need the full object. If your function really depends on a full User instance, you would ask for the full instance.

Anyhow, I think I've made my point - I think this feature solves a problem, but no one seems to agree with me. 😅

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

No branches or pull requests

2 participants