TypeScript: Satisfies does not work with const assertion

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.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 28
  • Comments: 23 (5 by maintainers)

Most upvoted comments

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

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://'

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.

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.

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.

As a workaround the Immutable<T> 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<T> is a candidate for a core Typescript utility type so that people can easily align with as const?

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

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)

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ā€).

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.

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.

https://github.com/microsoft/TypeScript/pull/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

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

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. šŸ‘

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.