Open mmkal opened 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
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
This is intentional. See #47920. They also mention a workaround if this behaviour is not desired for you: write satisfies T & Record<string, unknown>
.
@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
?
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.
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.
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. 😄
Yes. We are large, we contain multitudes.
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 })
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.
@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.
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:Right now, the
satisfies
operator does more than that. It's more likesatisfies and goes no further than
. Example:In the above, the value of
john
clearly does satisfy the interfaceUser
, but the compiler complains: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 typeConfig
- 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 validConfig
so we see invalid configuration errors in the wrong place. We have tried to usesatisfies
but hit the excess properties error for those properties not defined on theConfig
type. It's nested so we can't dosatisfies Config & Record<string, unknown>
except for top-level properties.