microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
99.17k stars 12.3k forks source link

Make `satisfies` configurable to allow excess properties in nested values #52999

Open mmkal opened 1 year ago

mmkal commented 1 year ago

Suggestion

🔍 Search Terms

satisfies + (known properties, unknown properties, excess)

✅ Viability Checklist

My suggestion meets these guidelines:

⭐ Suggestion

When using satisfies, the value should be allowed to have extra properties.

📃 Motivating Example

Satisfy is defined this way when I Google it:

meet the expectations, needs, or desires of (someone).

Right now, the satisfies operator does more than that. It's more like satisfies and goes no further than. Example:

interface User {
  name: string
}

const john = {
  name: 'John',
  email: 'john@gmail.com',
} satisfies User

In the above, the value of john clearly does satisfy the interface User, but the compiler complains:

Type '{ name: string; email: string; }' does not satisfy the expected type 'User'.
  Object literal may only specify known properties, and 'email' does not exist in type 'User'.

The first line of this is straight-up untrue! And it'd be really useful to be able to say "I have an object which I know is assignable to User, but it also has some specific extra stuff which I still care about.

💻 Use Cases

This first arose writing a very large, nested, complex config for a CMS. Right now it's a huge as const expression, which we have to carefully align with a type Config - but the type only defines some of the properties a config can have at compile time. The value is later passed to a function which expects a valid Config so we see invalid configuration errors in the wrong place. We have tried to use satisfies but hit the excess properties error for those properties not defined on the Config type. It's nested so we can't do satisfies Config & Record<string, unknown> except for top-level properties.

martinwepner commented 1 year ago

For me and probably many others its important that satisfies works as it does. However have you tried the following? For your use-case I always check like this:

interface User {
  name: string
}

const john = {
  name: 'John',
  email: 'john@gmail.com',
}

// compile time check if john is assignable to User
;(x: typeof john): User  => x

// or in your case:
type Config = {
  key1: string
} // & { <A_COMPLEX_TYPE> }
const config = {
  key1: "config1",
  // keys with values of A_COMPLEX_TYPE
  keyNotInConfig: "foo"
}

// compile time check if config is assignable to Config
;(x: typeof config): Config  => x

Playground link

PS: If you want to check for equivalence you can do this:

interface User {
  name: string
}

const john = {
  name: 'John',
  email: 'john@gmail.com',
}

// compile time check if john is equivalent to User
;(x: typeof john): User  => x
;(x: User): typeof john  => x

Playground link

MartinJohns commented 1 year ago

This is intentional. See #47920. They also mention a workaround if this behaviour is not desired for you: write satisfies T & Record<string, unknown>.

mmkal commented 1 year ago

@MartinJohns thank you, I missed that this was intentional behaviour first time I read that.

Re those workarounds:

satisfies T & Record<string, unknown>:

Does not work for nested types. The Config type I mentioned above looks something like:

interface Config {
  collections: Array<{name: string; folder: string; fields: Field[]}>
  backend: {repo: string; branch: string}
}

type Field = {name: string; translate?: boolean} & (
  | {widget: 'string'}
  | {widget: 'markdown'}
  | {widget: 'number'}
  | {widget: 'object'; fields: Field[]}
  | {widget: 'list'; field: Field}
  | {widget: 'list'; fields: Field[]}
  | {widget: 'select'; options: string[]}
  | {widget: 'code'}
  // | ...many more options
)

Where there are lots of complex field types in the Field union - and note that they can go arbitrarily nested via the object and list field types. So using & Record<string, unknown> isn't practical since most of those field types also allow excess properties.


;(x: typeof john): User => x:

This works, in that it gives errors for invalidly-defined values, but the reason I made this issue is I want to be able to take advantage of satisfies - I want developers to get autocomplete when typing fields, etc. This workaround loses all of that and puts the error on a random, separate, deleteable-looking runtime function. I thought that kind of no-op identity function that is useful at compile time only was what satisfies was partly seeking to eliminate.


One other workaround ~that really does work and doesn't have any noticeable downsides~:

interface User {
  name: string
}

const john = {
  name: 'John',
  // @ts-expect-error
  email: 'john@gmail.com',
  birthDate: '2000-01-01',
} satisfies User

Since luckily, the compiler only looks for the first excess property. But it seems pretty strange that typescript would push users into doing something like this.

Edit: scratch that: doesn't quite work. The compiler reports no errors at all after the first excess property one. You do get autocomplete though so still arguably the least bad.


Having said that, I do understand that there was a reason to disallow excess properties by default as explained in #47920. I've retitled this issue to "Make satisfies configurable to allow excess properties" - maybe there could be some kind of syntax like satisfies extends User to allow for this use case? @RyanCavanaugh curious if there was any internal discussion of allowing excess properties via extra syntax? Or is there a better workaround than // @ts-expect-error?

RyanCavanaugh commented 1 year ago

ts-expect-error should always be your last resort; do literally anything else first.

If you're writing this construct exactly once (or some very small number of times) in your codebase, which it sounds like you are, I think it's just way better to write the slightly awkward variant:

const config = { /* huge thing */ } as const;
config satisfies Config;

You won't get error spans inside the object literal, but the error message will give you an unambiguous key path to the offending property if the literal is as const.

if there was any internal discussion of allowing excess properties via extra syntax

Of course, but generally we don't want to "spend" syntax unless it's absolutely critical to the language.

mmkal commented 1 year ago

If you're writing this construct exactly once (or some very small number of times) in your codebase, which it sounds like you are, I think it's just way better to write the slightly awkward variant:

Yes there's only one config, but it's updated frequently. So it isn't just error spans, it's getting autocomplete on Field properties, etc. Developers have found the config hard to understand and I think intellisense would help a lot.

we don't want to "spend" syntax unless it's absolutely critical to the language

I don't know if I can make the case that it's absolutely critical to the language. But without a way to do this it does seem like perfectly-innocent users doing legitimate things are being forced to choose between bad DX and writing bad code like ts-expect-error.

fatcerberus commented 1 year ago

I admit I'm a bit amused by this issue - it seems that the majority of people reporting issues with excess property checks think these checks are a core part of the type system and expect them to be much more comprehensive than they are (hence #12936), while you actually want a way to relax them. 😄

mmkal commented 1 year ago

Yes. We are large, we contain multitudes.

martinwepner commented 1 year ago

Ah nice!

const config = { /* huge thing */ } as const;
config satisfies Config;
// is much better than 
;(x: typeof config): Config => x

Concerning the autocomplete, what about this:

type Config = { /* huge thing */ }
const Config = <TConfig extends Config>(config: TConfig) => config
const config = Config({ notInConfig: 42 })
dkrieger commented 1 month ago

from the linked issue:

Side note: It's tempting to say that properties aren't excess if all of the satisfied type's properties are matched. I don't think this is satisfactory because it doesn't really clearly define what would happen with the asserted-to type is Partial, which is likely common [...]

the current behavior is confusingly inconsistent with how typescript works in general, and it didn't need to uniquely answer the above question in the first place, because typescript already did when deciding how passing arguments to functions would work. it fails the basic intuition of how "satisfies" ought to behave:

if X satisfies Y, then X can be used as a Y wherever Y is used

respectfully, it seems that over-thinking this led to an implementation where only a subset of relationships fitting that basic definition of X don't upset the compiler. this strikes me as a situation where people with legacy code or preferences to protect should enable a compiler flag, but the semantics ought to be fixed so that X is as broad as the above formulation defines it.

no errors:

type Foo = {foo: string}

const foo = {foo: 'bar', hello: 'world'}

const getFoo = (f: Foo): void => {}

getFoo(foo)

error

type Foo = {foo: string}

const foo = {foo: 'bar', hello: 'world'} satisfies Foo // error

const getFoo = (f: Foo): void => {}

getFoo(foo)

this behavior is inconsistent with the rest of the language and astonishing in a bad way, regardless of how useful some may find it.

RyanCavanaugh commented 3 weeks ago

@dkrieger consider if you wrote something like this

interface Dimensions {
  width: number;
  height?: number;
  depth?: number;
}

const p = { width: 20, detph: 20 } satisfies Dimensions;

The only thing saving you here is excess property checks (EPC). Not erroring on this line is indefensible IMO - if satisfies isn't providing this error, it's not doing its stated job.

There's a directionality problem here - if satisfies didn't do EPC, there'd be no way to get an error on this line, and the operator is useless toward that goal. But in a world where satisfies does EPC (this one), it's something you can opt out of with & Record<.... It's obviously better to design it in a way where both scenarios can be satisfied, so that's what we did.