Giter Club home page Giter Club logo

Comments (22)

fatcerberus avatar fatcerberus commented on May 1, 2024 3

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

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024 2

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:

For the most part where people want exact types, we'd prefer to fix that by making EPC smarter.

This is me wanting a way to make EPC smarter! The EPC I get with satisfies is very helpful, and I'd like the opportunity to use it in more contexts - that's it.

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.

from typescript.

Andarist avatar Andarist commented on May 1, 2024 1

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,
});

from typescript.

fatcerberus avatar fatcerberus commented on May 1, 2024 1

The basic confusion here comes down to the fact that extends and satisfies both mean "assignable to", the only difference between them is that extends applies to types and satisfies to values. EPC is a check done on values; T extends U as a generic constraint enforces that the type T is assignable to the type U; EPC doesn't apply because there's no value involved at the point that check happens. T satisfies U is a category error because the LHS is a type, not a value.

FWIW, I also consider your feature request to be a de facto request for exact types since the behavior you want is precisely const T extends Exact<Foo>1. i.e. anything with more keys than Foo violates the constraint, but we still want to infer the types for each property.

Footnotes

  1. This syntax is itself instructive: the const keyword comes before the type parameter because it's not actually part of the constraint - it's a modifier that affects inference/typechecking for the value(s) it applies to. If there's a path forward here that doesn't involve exact types, I'd imagine this pattern is what would have to be emulated.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

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 satisfies would still be much appreciated.

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 !

from typescript.

Andarist avatar Andarist commented on May 1, 2024

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.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

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.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

@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"],
});

db is a generically typed representation of the user's database schema, which is aware of what models it contains and what field each model has. so typescript is able to know that "title", "content", and "authorId" are all valid fields on the "post" model. "wrong" however is not. what i'm trying to get to is a situation where typescript will show the "wrong" property as a type error, to let the user know they've done something wrong. this is exactly the behaviour you get if you use satisfies at the call site:

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!

from typescript.

jcalz avatar jcalz commented on May 1, 2024

This is essentially a duplicate of #12936, since we're not looking for all of satisfies's behavior, just excess property checking. Until and unless TS supports this, it's much easier to write code that doesn't care about excess properties than it is to write code that forces TypeScript to care about them. Especially because it's always going to be trivial to work around such code, by just someone widening the value before passing it.

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?

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

@jcalz i'm not sure this is a duplicate, as i do want all of satisfies' behaviour (at least as i understand it): excess property checking AND the preservation of literal types - both are necessary.

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.

from typescript.

RyanCavanaugh avatar RyanCavanaugh commented on May 1, 2024

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. satisfies and extends do the same thing - check that the right operand is a subtype of the left operand.

from typescript.

jcalz avatar jcalz commented on May 1, 2024

There's already const type parameters for literal types.

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 T extends Foo won't complain against excess properties on T because T will hold the information. Excess property checking is similar to having exact types, and there's overlap, but it's not the same feature. Even with "normal" excess property checking, nothing stops

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 bar will remember y, even though foo will not. Only const foo: Foo = {x: "", y: ""} is an error.

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.

from typescript.

RyanCavanaugh avatar RyanCavanaugh commented on May 1, 2024

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 { a: number, wrong: number } for T, so would infer T to be that type. Then we'd check that { a: number, wrong: number } is a subtype of { a: number }. It is, because extra keys are allowed in subtyping. So the call succeeds.

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

satisfies does not have special additional type behavior that extends doesn't

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

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 satisfies and calling a normal non-generic function behave differently depending on whether they are given a variable or a literal value. While with a generic there is only one behaviour.

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 satisfies being different to extends comes from the lack of symmetry w/r/t excess property checking. If satisfies didn't do excess property checking I wouldn't have this expectation.

from typescript.

RyanCavanaugh avatar RyanCavanaugh commented on May 1, 2024

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 T", and (without exact types) extra keys are part of that more-specific type information

from typescript.

RyanCavanaugh avatar RyanCavanaugh commented on May 1, 2024

I completely understand that you're asking for a relation between types where more-specific property types are allowed, but not extra keys. But satisfies isn't that relation. You're misattributing the cause of why you see the error in one context but not the other.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

You're misattributing the cause of why you see the error in one context but not the other.

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 const assignment rather than the satisfies that is triggering EPC in this case? Because const x: Foo = { a: 1, b: 2} also triggers EPC and would also cause a type error? Or is the cause of the error in this context something else?

from typescript.

fatcerberus avatar fatcerberus commented on May 1, 2024

The cause is specifically that the object literal being assigned to x is contextually typed by Foo. The expression x satisfies T roughly means "typecheck x as if it's being assigned to something of type T". So if x is an object literal, you get EPC.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

The expression x satisfies T roughly means "typecheck x as if it's being assigned to something of type T".

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 x as if it’s being assigned to something of type Foo, and assign its actual/literal type to the type variable T”. That feels like pretty much the same thing in a different context - but I don’t understand how typescript works under the hood so maybe there’s a crucial difference?

Ofc it wouldn’t have to use the satisfies keyword, it could be const foo = <U extendsWithEPC T> (x: U) => x… but that feels needlessly wordy and confusing - and I would never expect EPC and extends to go together. While my expectation for functionality that uses the satisfies keyword is that it would have EPC.

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 extends works: anything with a superset of Foo’s properties should typecheck fine. But satisfies does trigger EPC here, which is why it feels like something different to extends. What am I missing? Why are they the same thing? Why do they have different names?

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 foo(y) example would not, with the satisfies generic described in this feature request, cause a type error), and the errors can be a bit weird due to the never types, but so far it is doing the job. I wish I understood why it's working for nested objects, but I at least have a bunch of tests around it so I'll know if I accidentally break it.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

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 const keyword in generics. High likelihood I'll be using this terminology incorrectly, but maybe something like T narrows Foo or narrow T extends Foo would get across the intent?

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.

from typescript.

rsslldnphy avatar rsslldnphy commented on May 1, 2024

Based on now understanding the distinction of satisfies => values and extends => types, would any of these options be more viable as a proposed feature request for this kind of behaviour?

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).

from typescript.

juanrgm avatar juanrgm commented on May 1, 2024

I commented the same here and the possibility of replacing satisfies by Exact here.

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 satisfies but...

  • You must add it in every part of the code.
  • If you forget add it there will be no error.

Or you can try to emulate Exact but...

  • If you have recursive types is complex.
  • The performance will decrease, especially if you use it in recursive types.
  • The warning of excesive property affects to all types, so finding the error is complicated.

<T satisfies User>(input: T) or <T extends Exact<User>>(input: T) would solve all the problems.

from typescript.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.