-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Support satisfies
as a constraint on a function's generic type parameter
#58031
Comments
You might want to take a look into the recent improvement from #55811 type Foo = { a: number };
const foo = <T extends Foo>(x: { [K in keyof T & keyof Foo]: T[K] }) => x;
foo({
a: 1,
// Object literal may only specify known properties, and 'wrong' does not exist in type '{ a: 1; }'.(2353)
wrong: 2,
}); |
nice!! thank you, that gives me the behaviour that i need. it's pretty complex to represent so a simpler way to do it via i haven't got it fully working, am still getting some weird errors with other (optional) keys in the constrained type seeming to lose some type information, but this is a big step forward, and once i've managed to put together a minimal reproduction of the issue i'm now facing i'll ask on stack overflow. thanks again @Andarist ! |
It has currently some limitations (like #56910 ). If you are hitting some other thing - please let me know. If you provide a repro I could take a look at it. |
ok i think i've got it working with a fairly monstrous type. forgive the messiness as i'm pretty new to (relatively) more advanced typescript stuff. if there are ways to simplify this or any obvious problems i'd really appreciate a pointer! i found i had to treat required and optional properties separately, but maybe there's an easier way to do this? type OptionalKeys<T extends Record<string, unknown>> = {
// eslint-disable-next-line
[P in keyof T]: {} extends Pick<T, P> ? P : never;
}[keyof T];
type RequiredKeys<T extends Record<string, unknown>> = {
// eslint-disable-next-line
[P in keyof T]: {} extends Pick<T, P> ? never : P;
}[keyof T];
type Satisfies<T, Base> =
// recur if both the generic type and its base are records
T extends Record<string, unknown>
? Base extends Record<string, unknown>
// this check is to make sure i don't intersect with {}, allowing any keys
? (keyof T & RequiredKeys<Base> extends never
? unknown
: {
[K in keyof T & RequiredKeys<Base>]: Satisfies<
T[K],
Base[K]
>;
}) &
// this check is to make sure i don't intersect with {}, allowing any keys
(keyof T & OptionalKeys<Base> extends never
? unknown
: {
[K in keyof T & OptionalKeys<Base>]?: Satisfies<
T[K],
Base[K]
>;
})
// if the generic type is a record but the base type isn't, something has gone wrong
: never
: T extends (infer TE)[]
? Base extends (infer BE)[]
? Satisfies<TE, BE>[]
// if the generic type is an array but the base type isn't, something has gone wrong
: never
// the type is a scalar so no need to recur
: T; which leads to desired behaviour: type Foo = { a: { b?: number }[] };
const foo = <T extends Foo>(x: Satisfies<T, Foo>) => x;
foo({ a: [{}] }); // typechecks
foo({ a: [{ b: 3 }] }); // typechecks
foo({ x: 2 }); // error
foo({ a: [{}], x: 2 }); // error
foo({ a: [{ b: 2, x: 3 }] }); // error
foo({ a: [{ x: 3 }] }); // error EDIT - ignore this, i thought it was working but it isn't (in my codebase specifically), in a way that i haven't been able to minimally reproduce yet. |
I'm a little confused about the use case honestly - if you don't want subtypes, why is the function generic? Also note that EPC is more of a lint check than a proper type rule, and it's very easy to sneak extra properties in simply by, e.g., assigning the object literal to a variable first. See #12936 |
@fatcerberus the specific use case i want this for is for an ORM that allows users to specify the types of their models such that queries return strongly typed results. the aim is for users to get useful type hints when writing queries as literal objects. For example, in the below code: const result = await db.create("post", {
data: { title: "hello", content: "it me", authorId: "123", wrong: 2 },
returning: ["authorId"],
});
const result = await db.create("post", {
data: { title: "hello", content: "it me", authorId: "123", wrong: 2 },
returning: ["authorId"],
} satisfies CreateParams<Models, M>); but ideally i'd like to build this check into the function itself. it's fine to sneak extra properties in, they will be safely ignored. the purpose of the typing here is only to help the user of the library write correct code when writing literal queries. there's clearly something very odd going on with the types in my project though, they're behaving weirdly in a way i've yet to figure out how to replicate in a ts playground EDIT - so yes, as you say, the intention is for it to be more of a lint check than a proper type rule. if there's an alternative / better way to achieve this i'd really appreciate a pointer! |
This is essentially a duplicate of #12936, since we're not looking for all of I don't understand why you'd need this if you just want the user to write correct code, though. IntelliSense should prompt for the known keys, and not for unknown ones. Why do you need an error for unknown keys? |
@jcalz i'm not sure this is a duplicate, as i do want all of if i didn't need the preservation of literal types, i wouldn't have to use a generic, and excess property checking would work fine. if there's a better/simpler way to achieve both those things i'd be v keen to take a different approach! you're right that intellisense will prompt for the known keys and not unknown ones, but if the user types or copy/pastes an incorrect one there will then be no feedback to them that they've made a mistake. basically the reason i want an error for unknown keys is the same reason i find them useful when not using a generic - they let me know when i've made a mistake! i feel like this is uncontroversial in the case of normal excess property checking, so the fact it seems controversial in this case is suggesting to me that i must be fundamentally misunderstanding or missing something, but i don't know what. |
I don't understand the distinction either. Either you're subtyping or you're not. If you don't want extra-keys subtyping, the feature you need is exact types. |
There's already Excess property checking is only supposed to be "normal" in cases where TS promptly forgets the excess property. The mistake isn't "there's an extra property" but "there's a property TS can't possibly remember". So interface Foo {x: string}
interface Bar extends Foo {y: string}
const bar: Bar = {x: "",y: ""}
const foo: Foo = Bar; // <-- nothing can ever stop this That's allowed because Without #12936, there's no way to say universally that excess properties are "a mistake" by their mere presence. With the current state of TS, it's going to be much easier to get out of the mindset that excess properties are "a mistake" and get into the mindset that it's just how structural typing works. That lets you write code that doesn't explode in the face of excess properties and move on, instead of struggling to represent a concept TS just doesn't know how to deal with. |
Like, let's say this operator existed type Foo = { a: number };
const foo = <T satisfies Foo>(x: T) => x;
foo({ a: 1, wrong: 2 }) What would happen? During inference we'd see a candidate It's the same as if you had written this: type Foo = { a: number };
const p = { a: 1, wrong: 2 };
p satisfies Foo; // it does
|
I appreciate your time with this - going to make one last attempt to try to articulate where my confusion is. Yes, once something is assigned to a variable, all bets are off. However, both Extending your example a bit (here's a playground link): type Foo = {a: number}
// Satisfies works differently given a literal vs a variable
const x = {a: 3, b: 4 } satisfies Foo // it doesn't
const y = {a: 3, b: 4} as const;
y satisfies Foo // it does
// --------------
// Calling a function works differently given a literal vs a variable
const f1 = (x: Foo) => x;
f1({a: 3, b: 4 }) // doesn't work
f1(y) // this works fine
// --------------
// However, calling a generic function works the same in both cases
const f2 = <const T extends Foo>(x: T) => x;
f2({ a: 3, b: 4 }) // works - and i want a way to stop it from working
f2(y) // also works - and **should continue to work** I guess my feature request comes from the (perhaps misplaced) intuition that it should be possible for there to be symmetry here, and my understanding of |
You can do this: const f2 = <const T extends Foo>(x: NoInfer<T>) => x; Your objection will be "But I still want to use the more-specific type information of |
Ok I'm giving up now but just to say one last time that I do not want exact types. I want a way to tell Typescript to be smarter with excess property checking. Which I think aligns pretty well with what you wrote on the exact types issue:
This is me wanting a way to make EPC smarter! The EPC I get with As I say, I appreciate your time. I'm frustrated that I didn't come to an understanding as to why this isn't a good idea, but maybe as I learn more I will. |
I completely understand that you're asking for a relation between types where more-specific property types are allowed, but not extra keys. But |
Ah ok, that is helpful, thanks. In that case, what is it that causes EPC to happen when doing something like this? type Foo = { a: number };
const x = { a: 1, b: 2 } satisfies Foo; Is it the |
The cause is specifically that the object literal being assigned to |
Right, this exactly matches my understanding, so I guess I’m still confused as to what I’m missing. The proposal for the new syntax is basically, given a simple example: const duplicateFoo = <T satisfies Foo>(x: T): T[] => [x, x] ...“type check the argument Ofc it wouldn’t have to use the For eg let’s say as well as const x = { a: 1 } satisfies Foo we had the (imaginary) syntax const x = { a: 1 } extends Foo I would not expect the second example to do EPC because that’s just not how And, apologies for flogging the dead horse of "why this proposal is not the same as exact types": I've read the exact types issue several times now, and I cannot see how it and this proposal are the same, but maybe I'm completely misunderstanding exact types. It's probably easiest to explain what I see as the differences by way of examples: //given a type `Foo`
type Foo = { a: number };
// a function that takes anything that satisfies foo, with EPC, and retains its literal type
const fnGenericSatisfies = <T satisfies Foo>(x: T) => x;
// a function that takes exactly `Foo`, no extra properties allowed
const fnExact = (x: Exact<T>) => x;
// a function that extends an `Exact` version of `Foo` - but what does it
// even mean to extend an `Exact` type?
// either it's meaningless/not possible, or the type is no longer `Exact`,
// or it's an `Exact` type with extra fields.... this imo is one of the
// problems with the idea of exact types. it's not clear what to do in
// this situation. so going to ignore this third case for the remaining
// examples
const fnGenericExact = <T extends Exact<Foo>>(x: T) = x;
// literal case with valid argument
fnGenericSatisfies({ a: 1 }) // no error => { a: 1 }
fnExact({ a: 1 }) // no error => Foo / { a: number }
// literal case with argument that has extra properties
fnGenericSatisfies({ a: 1, b: 2 }) // type error due to EPC
fnExact({ a: 1, b: 2 }) // type error due to "exactness" of type
// passing a variable
const x = { a: 1 };
fnGenericSatisfies(x) // no error => { a: number }
fnExact(x) // maybe this works, or maybe it's an error bc `typeof x` is not an `Exact` type ?
// passing a variable with const type
const x = { a: 1 } as const;
fnGenericSatisfies(x) // no error => { a: 1 }
fnExact(x) // maybe this works, or maybe it's an error bc `typeof x` is not an `Exact` type ?
// passing a variable with extra properties
const x = { a: 1, b: 2 };
fnGenericSatisfies(x) // no error => { a: 1, b: 2 }
fnExact(x) // type error because `typeof x` is not exactly `Foo` Do I have some fundamental misunderstanding of what the exact types proposal is suggesting? In any case - and for anyone who comes across this issue with a similar problem, I've now got roughly what I need working using this utility type: export type DisallowExtraKeys<Base, T extends Base> = {
[K in keyof T]: T[K];
} & {
[K in keyof T as K extends keyof Base ? never : K]: never;
}; It even seems to work with nested objects but I honestly have no idea why or how, because there's no recursion in the type itself. This is its behaviour: type Foo = { a: number };
const foo = <T extends Foo>(x: DisallowExtraKeys<Foo, T>) => x;
foo({ a: 1 }); // OK
foo({ a: 1, b: 2 }); // Error: Type 'number' is not assignable to type 'never'.(2322)
const x = { a: 1 };
const y = { a: 1, b: 2 };
foo(x); // OK
foo(y); // Error: Argument of type '{ a: number; b: number; }' is not assignable to parameter of type '{ a: number; }'. Object literal may only specify known properties, and 'b' does not exist in type '{ a: number; }'.(2345) it's not quite what I want, it's stricter with variables in a way that I don't need and does feel closer to exact types (the |
The basic confusion here comes down to the fact that FWIW, I also consider your feature request to be a de facto request for exact types since the behavior you want is precisely Footnotes
|
Thank you!! That's a really clear explanation and the distinction 100% makes sense. And yes I was wondering about the relationship between what I was trying to ask for and the Thanks for your patience in explaining this - I now actually understand the objections to this feature request (and fortunately I have a workaround that lets me do what I need to without it). Would love to see something like this added in a way that doesn't require full on exact types, but if this isn't a common need I would definitely understand hesitance to add more syntax to the language. FWIW a helpful commenter on Reddit shared this approach to triggering EPC for generic types that nearly gets me exactly what I want (except it looks like literal types aren't preserved): type Foo = {a:number}
const foo = <const T extends Foo, _EPC = unknown>(x: T & _EPC) => x.a
foo({a: 1}) // typechecks, but as `number`, not `1`
foo({a: 1, b: 2}) // type error
const x = {a: 1, b: 2}
foo(x) // typechecks I have no idea why this does what it does. |
Based on now understanding the distinction of const f1 = (x: Foo as const) => x
const f2 = (x: const Foo) => x
const f3 = (const x: Foo) => x
const f4 = (x: satisfies Foo) => x I hope the above proposals make a bit more sense, and make the intention of the functionality a bit clearer. (Also, happy to stop posting on this issue if it is going nowhere and is unhelpful, don't want to take up your time needlessly). |
I commented the same here and the possibility of replacing There is not a simple way of using a generic type with excess property checking (playground): type User = {
name?: string
enabled?: boolean
}
const f1 = <const T extends User>(input: T): T => input
f1({ enabled: true }).enabled; // [success] "enabled" is `true`
f1({ enabled: true, surname: "" }).surname; // [error] no error
const f2 = <const T extends User, $Exact = unknown>(input: T & $Exact): T => input
f2({ enabled: true }).enabled; // [error] "enabled" is `boolean | undefined`
f2({ enabled: true, surname: "" }); // [success] property error
const f3 = <T extends User>(input: T): T => input
f3({ enabled: true } satisfies User).enabled; // [success] "enabled" is `true`
f3({ enabled: true, surname: "" } satisfies User); // [success] property error
type ExactKeys<T1, T2> = Exclude<keyof T1, keyof T2> extends never ? T1 : never;
type Exact<T, Shape> = T extends Shape ? ExactKeys<T, Shape> : never;
const f4 = <const T extends User>(input: Exact<T, User>): T => input
f4({ enabled: true }).enabled; // [success] "enabled" is `true`
f4({ enabled: true, surname: "" }); // [success] property error You can use
Or you can try to emulate
|
This issue has been marked as "Working as Intended" and has seen no recent activity. It has been automatically closed for house-keeping purposes. |
I can add to this with a sample use case: In my test files, I provide a subset of the translations file, so that those translations can be used in the test. I do this with /** register the translations to ensure that no translations are unnecessarily mocked */
declare function makeTranslations<T extends PartialTranslationsFile>(translations: T): T;
const translations = makeTranslations({
customer: "testCustomer",
close: "testClose",
} satisfies PartialTranslationFile);
…
it("shows stuff", () => {
expect(button.innerText).toBe(translations.close.toUpperCase())
}) n.b. PartialTranslationFile is a recursively Partial version of I would like to be able to omit the I can’t just let |
Adding my own use-case here, too: import { pascalCase } from "string-ts"
type packageManagerSlugs = "pnpm" | "npm" | "yarn" | "bun"
type runtimeSlugs = "node" | "bun"
type Context = {
packageManager: PackageManagerSlug
setPackageManager(slug: PackageManagerSlug): void
runtime: RuntimeSlug
setRuntime(slug: RuntimeSlug): void
}
const context: Context
function doWithContext<T extends "packageManager" | "runtime">({ key, options }: { key: T, options: Options<T> }) {
const value = context[key]
const setValue = context[`set${pascalCase(key)}`] // type error here
...
} The type error occurs because function doWithContext<T exact "packageManager" | "runtime">({ key, options }: { key: T, options: Options<T> }) whereby |
🔍 Search Terms
"satsifies", "satisfies generic parameter".
i found two similar/related feature requests, but they felt different enough to warrant being a separate feature request:
satisfies
operator on functions #51556satisfies
in type declaration #52222✅ Viability Checklist
⭐ Suggestion
Currently, from what I can tell, is not possible to mimic the behaviour of
satisfies
on a function's generic type parameter - i.e. to define a function with a generic parameter that allows inference of a literal type that conforms to that generic parameter, while still benefitting from excess property checking. Because the only option for a generic type parameter isextends
, there's no way (that I can see) to prevent extra keys in the inferred generic type.📃 Motivating Example
A bare bones example of what this change would make possible:
What I would really like to be able to write:
💻 Use Cases
The reason I want this functionality is that I'm writing an ORM and would like to restrict the keys passed to the
create
function to only the keys present in the model. To be clear I do not need a runtime check and any extra keys actually in the model are fine. The reason I want this feature is to provide a good experience when writing code like this with literal types:It is only possible to use
satisfies
at the call site of a function, and must be used at every call site, rather than being specified as part of the function's generic type parameter.I have not been able to find a workaround. If there is one, I would be very happy to hear it! If not I think the only option is to use
satisfies
at every call site.The text was updated successfully, but these errors were encountered: