Skip to content
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

Satisfies does not work with const assertion #51173

Closed
steverep opened this issue Oct 14, 2022 · 23 comments
Closed

Satisfies does not work with const assertion #51173

steverep opened this issue Oct 14, 2022 · 23 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@steverep
Copy link

Bug Report

πŸ”Ž Search Terms

satisfies, as const, literal

πŸ•— Version & Regression Information

  • This is a crash
  • I was unable to test this on prior versions because satisfies is new in 4.9.0 beta

⏯ Playground Link

Playground Link

πŸ’» Code

type Colors = "red" | "green" | "blue";
type RGB = readonly [red: number, green: number, blue: number];
type Palette = Readonly<Record<Colors, string| RGB>>;

const palette1 = {
    red: [255, 0, 0],
    green: "#00ff00",
blue: [0, 0, 255]   
    // Expect to pass but throws error
} satisfies Palette as const;

πŸ™ Actual behavior

Fails with error:

'const' assertions can only be applied to references to enum members, or string, number, boolean, array, or object literals.

πŸ™‚ Expected behavior

No error because it is a literal and satisfies should not change the type. Also, other type assertions work fine, so const should as well.

@fatcerberus
Copy link

fatcerberus commented Oct 14, 2022

Try reversing the order of satisfies and as const. Having the satisfies clause first makes the compiler think you’re trying to apply as const to an expression instead of a bare literal, so it’s rejected for the same reason as (1 + 1) as const would be.

Also, other type assertions work fine, so const should as well.

This is immaterial as as const can’t be applied to arbitrary expressions like other type assertions can.

@steverep
Copy link
Author

Try reversing the order of satisfies and as const. Having the satisfies clause first makes the compiler think you’re trying to apply as const to an expression instead of a bare literal, so it’s rejected for the same reason as (1 + 1) as const would be.

Yes, it works when reversed as I show in the playground link, but that just creates an unnecessary restriction to make sure everything in the satisfies type is readonly because the check will fail otherwise.

The compiler may be treating it as an expression, but it's not, and therein lies the bug.

@RyanCavanaugh RyanCavanaugh added the Not a Defect This behavior is one of several equally-correct options label Oct 14, 2022
@RyanCavanaugh
Copy link
Member

The compiler may be treating it as an expression, but it's not, and therein lies the bug.

It absolutely is an expression, though.

Regardless, f(g(x)) === g(f(x)) isn't an identity. It's completely correct to say that you must write as const satisfies T, since that's the only order of applying those operations that works.

@fatcerberus
Copy link

fatcerberus commented Oct 14, 2022

Regardless, f(g(x)) === g(f(x)) isn't an identity.

I don’t think that’s the ask here, to be fair. It seems like the intent is to do the satisfies check first, and only then apply the transformations inherent to as const. Which is not currently possible.

@RyanCavanaugh RyanCavanaugh removed the Not a Defect This behavior is one of several equally-correct options label Oct 14, 2022
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 14, 2022

Giving it some more thought, in the OP we can just reverse the order of operations, and it works, but arguably that actually doesn't generalize:

const j: { m: readonly [1, 2] } = { m: [1, 2] } satisfies { m: number[] } as const;

I'm not sure how to resolve this situation coherently. The implied algorithm seems to be:

  • Check the expression { m: [1, 2] } and see if it's assignable to of { m: number[] }
  • It is, because it has the type { m: number[] }
  • Now check the expression { m: [1, 2] } as const and see if it's assignable to { m: readonly [1, 2] }
  • It is

But in TypeScript, expressions only have one type. If we just "threaded through" the const context into the expression, this would happen:

  • Check the expression { m: [1, 2] } in a const context and see if it's assignable to of { m: number[] }
  • It's not, because an array literal in a const context acquires a readonly tuple type, and a readonly array isn't assignable to a mutable array

Another way to put it is that as const isn't a type-to-type transform, but rather it's a modifier that contextually changes the interpretation of literals, which is why it only makes sense to apply directly to expressions of a literal form.

The same thing is broadly true of contextual types in general, which again aren't a type-to-type transform, but rather have effects on the interpretation of literals and other expressions. In the original satisfies discussion we talked about whether the outer contextual type should matter -- I initially argued in favor of this because usually more contextual typing just only accepts more correct programs, but there were circularities and other problems introduced by this that ultimately made us decide against doing so. And already I've found some interesting cases where an unexpected benefit of satisfies is that you can use satisfies unknown to remove any outer contextual type in the rare cases where it's negatively affecting something. as const seems similar in spirit here and it'd be weird to thread-in the constness while not also threading-in the contextual type from an as T.

@fatcerberus
Copy link

Giving it some more thought, in the OP we can just reverse the order of operations, and it works, but arguably that actually doesn't generalize

Yep, hence this issue πŸ˜ƒ

The implied algorithm seems to be … [snip]

Indeed this seems to be what OP wants to do.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. labels Oct 14, 2022
@steverep
Copy link
Author

It absolutely is an expression, though.

As I understood from your writing in #47920, e satisfies T should return typeof e, which implies to me (and probably most users?) that the satisfies operator should not affect any further operations on e. So at the very least the error returned seems incorrect and confusing.

Regardless, f(g(x)) === g(f(x)) isn't an identity. It's completely correct to say that you must write as const satisfies T, since that's the only order of applying those operations that works.

Well, that depends on f and g, but if I go ahead and follow that through:

f(x) = typeof(x as const)
g(x) = typeof(x satisfies T) = typeof x

f(g(x)) = typeof((typeof x) as const) = f(typeof x)
g(f(x)) = typeof(typeof(x as const)) = f(x)

In other words, if the satisfies operator is widening the literals to their primitive types, then sure they are not equivalent. But if the satisfies operator is doing nothing more than a check on x, then we should view g(x) = x, and they are absolutely equivalent so long as T is adjusted accordingly.

And stating that there's only one order that works is not correct either. The following works just fine:

const a = [x satisfies T1, y satisfies T2] as const;

I need much more time to digest your latter comments as I am not an expert on the compiler code, but thanks for starting to pull a 180. πŸ‘

@fatcerberus
Copy link

That works because the const assertion applies to the array literal [ ... ], not the satisfies expressions directly.

What Ryan is saying is that the behavior you want isn't possible with the current way const assertions work because as const isn't a type transformation the way other type assertions are, i.e. it doesn't take something typed as number[] and give back a readonly [ 1, 2 ], it directly affects type inference of the literal it's applied to, namely by inferring narrower types to start with. So a naive implementation of this would still have the same problem as as const satisfies T does because it would be impossible to do the type check "before" the const assertion is applied (there's no "before").

@steverep
Copy link
Author

That works because the const assertion applies to the array literal [ ... ], not the satisfies expressions directly.

That's the part I'm stuck on. Why would it ever apply to the satisfies expressions if they are purely checks?

... because it would be impossible to do the type check "before" the const assertion is applied (there's no "before").

Isn't that exactly what it does in the case of [x satisfies T] as const? I view that as check x then apply as const to the type. And I view x satisfies T as const the same way.

In any case, you guys clearly know the compiler way better than me, but I think my expectations of what it "should" do are fairly logical. Thanks for taking the time to explain.

@fatcerberus
Copy link

fatcerberus commented Oct 15, 2022

That's the part I'm stuck on. Why would it ever apply to the satisfies expressions if they are purely checks?

It wouldn’tβ€”which is the problem here. as const applies to object/array literals, so even if satisfies T as const worked now, it wouldn’t do what you wantβ€”it would just infer the readonly types to begin with and subsequently fail the check. In order to do what you want the narrowing implied by a const assertion would need to be done as a separate step, which requires an architectural change to the compiler.

(in simpler terms { … } as const is one atomic expression potentially with a different type from { … } and breaking it apart, e.g. by putting a satisfies clause in the middle, makes no sense with the current architecture)

@fatcerberus
Copy link

fatcerberus commented Oct 15, 2022

It probably would have made more sense for const assertions to instead have been written as const { … }, in which case the limitation here would have been more obvious. as const isn’t really a type assertion in the normal senseβ€”it’s more like an operator you apply to an object literal that changes how its type is inferred upfront.

@steverep
Copy link
Author

(in simpler terms { … } as const is one atomic expression potentially with a different type from { … } and breaking it apart, e.g. by putting a satisfies clause in the middle, makes no sense with the current architecture)

Alright, I think I get what you're saying now. Basically the architecture didn't fully envision more than one post-expression operator, or at least not one like as const.

Thinking perhaps very naively, couldn't you just trick the compiler internally by doing something like:

{ ... } as const = ([{ ... }] as const)[0]

Makes the error go away in the playground at least.

@fatcerberus
Copy link

fatcerberus commented Oct 15, 2022

It’s not really an operator, that’s the thing. It’s a modifier. It’s hard to explain but basically { … } as const is a fundamentally different thing (to the compiler) than { … }. If you say [1] as const there’s never a point where you have a number[] that you can check with satisfies - it’s a readonly [1] right from the start simply because as const is present. The postfix syntax obscures this fact a bit, but that’s the reality. And given that it works this way, satisfies T as const doesn’t currently make sense.

@fatcerberus
Copy link

@steverep The confusion here is essentially linguistic: think β€œprairie dog” vs β€œprairie man”. Syntactically, the const assertion looks like the former to the compiler.

@tjx666
Copy link

tjx666 commented Oct 21, 2022

This would be useful in following case:

image

@RyanCavanaugh
Copy link
Member

@tjx666 please submit text, not screenshots. No one wants to spend multiple minutes typing in examples just to try some things out.

@steverep
Copy link
Author

It's also not accessible to anyone using a screen reader or with low vision

@tjx666
Copy link

tjx666 commented Oct 21, 2022

trackEvents.ts

type EventName = typeof events[number]['name'];
type TrackEvent = {
    name: string;
    id: number;
};
type FilterElementByName<
    Arr extends readonly TrackEvent[],
    N extends EventName,
> = Arr extends readonly [infer First, ...infer Rest]
    ? First extends TrackEvent
        ? First['name'] extends N
            ? First
            : Rest extends readonly TrackEvent[]
            ? FilterElementByName<Rest, N>
            : null
        : null
    : null;
export type TrackEvents = {
    [K in EventName]: FilterElementByName<typeof events, K>;
};

const events = [
    {
        name: 'event1',
        id: 10001,
        click_type: 'xxx',
        client_name: 'PS',
    },
    {
        name: 'event2',
        id: 10002,
        work_id: '',
    },
    {
        name: 'event3',
        id: 10003,
        work_id: '',
        export_status: 2 as 0 | 1 | 2,
    },
    // I want the element get the intellisence of TrackEvent
] satisfies TrackEvent[] as const;

export const trackEvents = events.reduce((obj, event) => {
    obj[event.name] = event as any;
    return obj;
}, {} as TrackEvents);

The type TrackEvents should be:

type TrackEvents = {
    event1: {
        readonly name: "event1";
        readonly id: 10001;
        readonly click_type: "xxx";
        readonly client_name: "PS";
    };
    event2: {
        readonly name: "event2";
        readonly id: 10002;
        readonly work_id: "";
    };
    event3:  {
        readonly name: 'event3',
        readonly id: 10003,
        readonly work_id: '',
        readonly export_status: 2 as 0 | 1 | 2,
    }
}

@cefn
Copy link

cefn commented Nov 1, 2022

As a workaround the Immutable type of @lauf/store aligns with the recursive readonly behaviour of as const in case you absolutely have to put up with the order in 4.9.

I typically end up with Immutable in my codebases for reasons like this, even where I'm not using the store itself - it's a tiny package anyway, and getting smaller in v2.0.0. Regardless it won't be bundled if imported as type-only.

Here's an example where I can derive CompanyId from the contents of an as const array, which is nevertheless known to be an array of Company items, giving me auto-completion.

import type { Immutable } from "@lauf/store"

export const companies = [
  {
    id:"Someone",
    url: `https://someone.io`,
    tags: [
      "analytics",
      "datascience"
    ],
    description: <>
      Quality management for data science product lifecycles
    </>
  }
] as const satisfies Immutable<Company[]>;

interface Company {
  id: string;
  url: `https${string}`
  tags?: string[], 
  description?: JSX.Element;
}

type CompanyId = typeof companies[number]["id"];

I've found this to be useful enough to need it in all my projects anyway, so maybe a Const is a candidate for a core Typescript utility type so that people can easily align with as const?

@3xau1o
Copy link

3xau1o commented Dec 27, 2022

just wrap the the literal with const assertion in parenthesis before adding satisfies
image

@belgattitude
Copy link

belgattitude commented Apr 6, 2023

Just sharing an alternative based on type-fest ReadonlyDeep.

That should work with any type (map, arrays, nesting...).

import type { ReadonlyDeep } from 'type-fest';

interface BaseConfig {
   url: string,
   navLinks: Array<{ title: string, href: string}>
}

export const siteConfig = {
   url: 'https://',
   navLinks: [ { title: 'Blog', href: '/blog' } ]
} as const satisfies ReadonlyDeep<BaseConfig>;

// eventually 
export type SiteConfig = typeof siteConfig;
// siteConfig.url => 'https://'

@Andarist
Copy link
Contributor

Yes, it works when reversed as I show in the playground link, but that just creates an unnecessary restriction to make sure everything in the satisfies type is readonly because the check will fail otherwise.

#55229 addressed this. I think that this addressed most of the concerns here - you still have to as const satisfies T (in this order) but that's not a defect and has been initially classified as working as intended. Given the mentioned PR, this can probably get closed. cc @RyanCavanaugh

@RyanCavanaugh
Copy link
Member

I agree; the adversarial example I made

const j: { m: readonly [1, 2] } = { m: [1, 2] } as const satisfies { m: number[] } ;

which errored at time of writing now works. I don't see anything left to do here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

8 participants