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

Support satisfies in type declaration #52222

Open
5 tasks done
MaximSagan opened this issue Jan 13, 2023 · 11 comments
Open
5 tasks done

Support satisfies in type declaration #52222

MaximSagan opened this issue Jan 13, 2023 · 11 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@MaximSagan
Copy link

MaximSagan commented Jan 13, 2023

Suggestion

satisfies should work in type declarations, similar to how it works currently.

i.e. similar to

const x = y satisfies Z;

we could have

type X = Y satisfies Z;

πŸ” Search Terms

satisfies, type

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

πŸ“ƒ Motivating Example

Say there is some library, 'to-upper', that deals with lower case letters, both Roman and Greek.

// external library 'to-upper@1.0.0'

export type LowercaseChar = 
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' 
  | 't'  | 'u' | 'v' | 'w' | 'x' | 'y' | 'z' |  'Ξ±' | 'Ξ²' | 'Ξ³' | 'Ξ΄' | 'Ξ΅' | 'ΞΆ' | 'Ξ·' | 'ΞΈ' | 'ΞΉ' | 'ΞΊ' | 'Ξ»' 
  | 'ΞΌ' | 'Ξ½' | 'ΞΎ' | 'ΞΏ' | 'Ο€' | 'ρ' | 'Οƒ' | 'Ο„' | 'Ο…' | 'Ο†' | 'Ο‡' | 'ψ' | 'Ο‰';
export type UppercaseChar = ...;
export const toUpper = (lower: LowercaseChar): UppercaseChar => ...

And I want to use this library, but I actually only need to deal with a type that is narrower than LowercaseChar. I only need to deal with vowels. So I make my type,

// my-types.ts

export type LowercaseVowel = 'a' | 'e' | 'i' | 'o' | 'u' | 'Ξ±' | 'Ξ΅' | 'Ξ·' | 'ΞΉ' | 'ΞΏ' | 'Ο‰' | 'Ο…';

and then I use it with 'to-upper'

// my-app.ts

import { lowerToUpper } from 'char.js';
import type { LowercaseVowel } from './my-types';

const myLowercaseVowel: LowercaseVowel = 'Ο‰';
toUpper(myLowercaseVowel);

All good.

However, now the maintainer of "to-upper" decides they don't want to deal with Greek characters anymore, so they make a breaking change. Being diligent and considerate of their users, they update the LowercaseChar type definition as such:

// external library 'to-upper@2.0.0'
export type LowercaseChar = 
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's'
  | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
...

And I update my dependencies to to-upper@2.0.0.
It's true that my code will break on type checking, but it will fail where I've called toLower(), because my LowercaseVowel (which includes lowercase Greek characters) no longer satisfies the parameter of toLower(), LowercaseChar, which doesn't.

What would be preferable is if I had defined LowecaseVowel explicitly with the constraint that it satisfies LowecaseChar, i.e.

import type { LowercaseChar } from 'char.js';

type LowercaseVowel = 'a' | 'e' | 'i' | 'o' | 'u' | 'Ξ±' | 'Ξ΅' | 'Ξ·' | 'ΞΉ' | 'ΞΏ' | 'Ο‰' | 'Ο…' satisfies LowercaseChar;

(Using the syntax suggested.)
If this were supported, I can see in the type declaration whether or not my narrow type satisfies the broader type.

πŸ’» Use Cases

Similar to the above example, this could be used to narrow usages of overly broad types like any in third-party libraries, e.g.

// third-party library

export type Payload = { data: any };
export function handlePayload(payload: Payload) {
  ...
}

// my satisfying library

import { type Payload, handlePayload } from 'third-party-library';

export type SpecialPayload = { data: { foo: string } } satisfies Payload;
export function handleSpecialPayload(specialPayload: SpecialPayload) {
   handlePayload(specialPayload);
   ...
}

// consumer of my library
import { type SpecialPayload, handleSpecialPayload } from 'satisfying-library';

const mySpecialPayload: SpecialPayload = { data: { foo: 'bar' } };
handleSpecialPayload(mySpecialPayload);

In this particular contrived example, the same thing could be done with using interfaces interface SpecialPayload extends Payload { data: { foo: string; } }, but I'm sure you could think of more complex examples where interfaces cannot be used.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Jan 13, 2023
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jan 13, 2023

This is pretty unlikely to happen, since you can already write:

type Satisfies<T extends U, U> = T;
type Test = Satisfies<string, string | number>;

or

type Satisfies<T extends U, U> = T;
type Test = string;
type Check_Test = Satisfies<Test, string | number>;

None of the use cases that motivated satisfies have good correspondences to type declarations.

@Jonas-Seiler-Wave
Copy link

@RyanCavanaugh
I was about to post a similar suggestion when I found this. I am unsure if it is different enough to warrant opening another issue. I hope it's ok to describe the gist of it here:
Basically I would like to apply satisfies to interface definitions to make sure a type properly narrows another type (and get auto completion for property names).

Without satisfies

interface Broad {
  property: string
}

interface NarrowA {
  property: "foo"
} 
//what if 'Broad' changes?

interface NarrowB {
  propertyy: "bar"
  //oops, typo
}

With satisfies

interface Broad {
  property: string
}

interface NarrowA {
  property: "foo"
} satisfies Broad 
// will no longer compile if 'Broad' changed

interface NarrowB {
  propertyy: "bar"
  //get's caught
} satisfies Broad

However, I am aware this would only really be a shorthand for something like this:

interface BP<T extends string> {
  property: T
}

type Broad = BP<string>
type NarrowA = BP<"foo">
type NarrowB = BP<"bar">

Should I even open a new issue for this? Of course I'd elaborate more there.

@ecyrbe
Copy link

ecyrbe commented Mar 17, 2023

@RyanCavanaugh The satisfies type does not have the same semantics as satisfies:

type Satisfies<T extends U, U> = T;
type Test = Satisfies<{
  hello: "world";
  and: "universe"
}, { hello: string }>; // pass

const test = {
  hello: "world";
  and: "universe"
} satisfies { hello: string }; // don't pass

is there a way today in TypeScript to have the first one to not succeed ?
i need this feature for Zodios library to detect users typo.
example :

export const edge = new Zodios(
  `/api/v1`,
  [ // api definition is narrowed to preserve string literals with generic helpers behind the scene
    {
      method: "get",
      path: "/results",
      alias: "getResults",
      paramaters: [ // typo, should be 'parameters', but typescript don't catch it since parameters are optional
        {
          type: "Query",
          name: "z",
          schema: z.string(),
        },
      ],
      response: z.string(),
    }
  ],
);

and i don't want to force users to write this, it would be a really bad developper experience:

export const edge = new Zodios(
  `/api/1`,
  [ // api definition is narrowed to preserve string literals with generic helpers behind the scene
    {
      method: "get",
      path: "/results",
      alias: "getResults",
      paramaters: [ // typo, is now catched since 'satisfies' don't allow excess properties
        {
          type: "Query",
          name: "z",
          schema: z.string(),
        },
      ],
      response: z.string(),
    }
  ] satistifes ZodiosApiDefinitions,
);

@kristiannotari
Copy link

kristiannotari commented Mar 29, 2023

I have another use case for this. Imagine you're defining some types associations starting from a type T:

type T = 'a' | 'b'

const VALUE_MAP = {
   'a': "hello",
   'b': 42
} satisfies Record<T, any> // ok

// here I can use VALUE_MAP['a'] to access the associated value

type TYPE_MAP = {
   'a': "hello"
   'b': 42
   'c': "I can put whatever I want here"
} satisfies Record<T, any> // error, no satisfies with types

// here I can use TYPE_MAP['a'] to access the associated type

From what I can see there's no way to both A) constraint the keys of my TYPE_MAP to be of type T AND B) to associate a specific type (possibly different for every key) manually, without referencing other maps/associations/generic types (the latter would need satisfies at the type level too btw)

@Offroaders123
Copy link

Offroaders123 commented May 11, 2023

I'm also interested in an addition with this, too.

My use-case is to be able to validate either a class, another interface, or an object against a type with an index signature, but without applying the index signature behavior itself.

The current language support almost exactly works like I need it to. The only issue is that the shape of a type cannot be checked against an index signature successfully, without adding the index signature to it. So, my goal is to validate that the keys on an object/class/interface/type should match that of an index signature type, without enabling indexing on the shape itself.

I wrote an example of doing with with the primitives and shapes which are valid within the JSON format:

// JSON primitive types

type JSONPrimitive =
  | string
  | number
  | boolean
  | JSONArray
  | JSONObject
  | null;

interface JSONArray<T extends JSONPrimitive = JSONPrimitive> extends Array<T> {}

interface JSONObject {
  [key: string]: JSONPrimitive;
}

// Validate that my own types are compatible with that of the JSON format.
// This is the only way to currently check one interface against another.

interface MyDataStructure extends JSONObject {
  hey: number;
}

// Possible syntax proposal to validate the type against the other type, without applying the index signature.

interface MyDataStructure satisfies JSONObject {
  hey: number;
}

// --------------------------

const myObject: MyDataStructure = {
  hey: 23
};

// @ts-expect-error
myObject.shouldNotBeIndexable;

// --------------------------

// shouldn't error, I would like to validate the class against the shape, not forcing it to have an index signature.
class MyDataStructureClass implements MyDataStructure {
  constructor(public hey: number = 32) {}
}

// Similar syntax proposal for classes as to that of the validation for interfaces.

class MyDataStructureClass satisfies JSONObject {
  constructor(public hey: number) {}
}


// --------------------------

function getValueForHey<T extends MyDataStructure>(myDataStructure: T): MyDataStructure["hey"] {
  return myDataStructure.hey;
}

// also shouldn't error, I'd like to check that the shape of the class is valid against the index signature, not that it includes the index signature.
getValueForHey(new MyDataStructureClass());

// Generic parameter 'satisfies' constraint syntax proposal

declare function getValueForHey<T satisfies MyDataStructure>(myDataStructure: T): MyDataStructure["hey"];

Essentially, I'd like to type-check that my own interfaces and classes are providing key values which are safe within the format I am defining values for. If I define a key on the type and it has a value not supported by the format spec lined out in the index signature/primitives union type, then it should error. I want to ensure that only the properties I am defining myself can be accessed on the type, I don't want the index signature behavior in this case. I only want it to enable type checking against the interface values.

*Edit: An extension to my initial comment, it would be great to be able to do this with generics as well.

@Offroaders123
Copy link

An alternative that could solve all of my changes mentioned above, would be if you could instead mark an index signature as use for type checking only. I think that may be a more elegant approach than the other ones here, as it essentially does exactly what I'm trying to do with the index signature, without having to change all of the places where index signatures could be used, just to enable the same functionality.

So, in a smaller example, this is my suggestion:

type JSONPrimitive =
  | string
  | number
  | boolean
  | JSONArray
  | JSONObject
  | null;

interface JSONArray<T extends JSONPrimitive = JSONPrimitive> extends Array<T> {}

// Neither of these should enable the standard index signature behavior, they should only validate that the types on a given shape you are validating have expected an acceptable value types.

// Alternative #1
interface JSONObject {
  [key: string]: satisfies JSONPrimitive;
}

// Alternative #2
interface JSONObject {
  satisfies [key: string]: JSONPrimitive;
}

Offroaders123 added a commit to Offroaders123/NBTify that referenced this issue May 11, 2023
Added support for using generics types with the NBTData class, and it's surrounding functional APIs!

Now if you know the shape of the NBT data going into the read function, you can pass that type into the read function generic parameter. This makes it so it can be a little easier to manage the types for the NBT structures going in and out of the library.

This is better than just plain type assertions on top of the `NBTData.data` property, because now the `NBTData` class type itself can hold both the NBT file metadata itself, and the shape of the data that it holds. I'm planning on using this to describe the full NBT file data structure and file metadata for world save data entries, so then you know things like the endian format, compression type, and what the actual NBT in the file is. So `NBTData` can be a full representation of the entire NBT file and how to make it with NBTify! This will work great once I eventually need to make NBT-generating classes which can make defaults for the these files when necessary. The class will simply implement the file's `NBTData` interface shape (including the NBT structure, file metadata), then the class will be fully type-safe with all of the information needed to make a fully symmetrical data tree to that of what's generated by the vanilla game :)

Currently, my use of validating the NBT data trees against the `CompoundTag` interface is currently typed too-strictly for what I'm trying to use it for, and it's forcing the interfaces and classes to require establishing index signatures, which are partially eliminating what I want the interface definitions to enable in the first place. I don't want my interfaces to allow the access and additions of unspecified properties, I only want to check that the values I am defining are compatible with the NBT primitive types.

microsoft/TypeScript#52222 (comment)
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html

I created a new issue (#28) under NBTify to track the development of this typing issue in terms of how NBTify is handling this problem.
Offroaders123 added a commit to Offroaders123/NBTify that referenced this issue May 12, 2023
Removing the API-safe `CompoundTag` type checking for the library entry points, in the meantime. Without the index signature key value checking behavior that I need to accomplish the value type checking, there isn't another way to create API designs that will make the compiler happy, unfortunately. So this means I'm going to make all objects acceptable into NBTify, on the type level at least. Since I can't use type checking to validate the types for the shapes I'm passing in, I will just have to allow them in, and make sure that the shapes have appropriate value types.

While working on this revert, I looked into the `object` type, and realized it's kind of similar in a way as to what I'm trying to do with my primitive interface type checkers (`ListTag` and `CompoundTag`).

> Introduction to TypeScript `object` type
> The TypeScript `object` type represents all values that are not in primitive types.
>
> The following are primitive types in TypeScript:
>
> `number`
> `bigint`
> `string`
> `boolean`
> `null`
> `undefined`
> `symbol`

For my object definitions, I'm trying to define an object with keys that are only of a select few types. For the `object` type, it represents all JavaScript types that aren't a primitive type. So with this change, NBTify simply only checks if your `CompoundTag` is none of the primitive values mentioned above, which is better than just using `any` at least. It would be better if the check could also specify that your `CompoundTag` object cannot have things like `Function`s, `symbol`s, `undefined`, `null`, things like that. I think that's my main reason for wanting to add parameter type checking for your `CompoundTag` use.

https://www.typescripttutorial.net/typescript-tutorial/typescript-object-type/

microsoft/TypeScript#52222
(#28)
Offroaders123 added a commit to Offroaders123/NBTify that referenced this issue May 19, 2023
Added support for using generics types with the NBTData class, and it's surrounding functional APIs!

Now if you know the shape of the NBT data going into the read function, you can pass that type into the read function generic parameter. This makes it so it can be a little easier to manage the types for the NBT structures going in and out of the library.

This is better than just plain type assertions on top of the `NBTData.data` property, because now the `NBTData` class type itself can hold both the NBT file metadata itself, and the shape of the data that it holds. I'm planning on using this to describe the full NBT file data structure and file metadata for world save data entries, so then you know things like the endian format, compression type, and what the actual NBT in the file is. So `NBTData` can be a full representation of the entire NBT file and how to make it with NBTify! This will work great once I eventually need to make NBT-generating classes which can make defaults for the these files when necessary. The class will simply implement the file's `NBTData` interface shape (including the NBT structure, file metadata), then the class will be fully type-safe with all of the information needed to make a fully symmetrical data tree to that of what's generated by the vanilla game :)

Currently, my use of validating the NBT data trees against the `CompoundTag` interface is currently typed too-strictly for what I'm trying to use it for, and it's forcing the interfaces and classes to require establishing index signatures, which are partially eliminating what I want the interface definitions to enable in the first place. I don't want my interfaces to allow the access and additions of unspecified properties, I only want to check that the values I am defining are compatible with the NBT primitive types.

microsoft/TypeScript#52222 (comment)
https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html

I created a new issue (#28) under NBTify to track the development of this typing issue in terms of how NBTify is handling this problem.
Offroaders123 added a commit to Offroaders123/NBTify that referenced this issue May 19, 2023
Removing the API-safe `CompoundTag` type checking for the library entry points, in the meantime. Without the index signature key value checking behavior that I need to accomplish the value type checking, there isn't another way to create API designs that will make the compiler happy, unfortunately. So this means I'm going to make all objects acceptable into NBTify, on the type level at least. Since I can't use type checking to validate the types for the shapes I'm passing in, I will just have to allow them in, and make sure that the shapes have appropriate value types.

While working on this revert, I looked into the `object` type, and realized it's kind of similar in a way as to what I'm trying to do with my primitive interface type checkers (`ListTag` and `CompoundTag`).

> Introduction to TypeScript `object` type
> The TypeScript `object` type represents all values that are not in primitive types.
>
> The following are primitive types in TypeScript:
>
> `number`
> `bigint`
> `string`
> `boolean`
> `null`
> `undefined`
> `symbol`

For my object definitions, I'm trying to define an object with keys that are only of a select few types. For the `object` type, it represents all JavaScript types that aren't a primitive type. So with this change, NBTify simply only checks if your `CompoundTag` is none of the primitive values mentioned above, which is better than just using `any` at least. It would be better if the check could also specify that your `CompoundTag` object cannot have things like `Function`s, `symbol`s, `undefined`, `null`, things like that. I think that's my main reason for wanting to add parameter type checking for your `CompoundTag` use.

https://www.typescripttutorial.net/typescript-tutorial/typescript-object-type/

microsoft/TypeScript#52222
(#28)
@Harpush
Copy link

Harpush commented Jun 7, 2023

I think it is kind of like const assertions on generic parameters instead of doing as const in calling code. It makes a lot of sense to say that T should satisfy and not should extend - especially for excess property checks. Right now there is no way to pass a generic parameter that extends objects union (non discriminate) with excess property checks without satisfies from outside.

@aryzing
Copy link

aryzing commented Nov 2, 2023

@ecyrbe makes a great point in support of a type level satisfies. It's fairly common to want to declare a type that's a subset of another while ensuring it remains compatible with the wider type.

@mattidupre
Copy link

Being able to quickly assert subtyping with satisfies would have a dramatic effect on my time spent mucking with complex generic types. Throwing in an extends means I don't have to dig through deeply nested type errors.

Could this also mitigate the need to create function declarations for compile-time asserts, i.e., declare function temp<T1>(): <T2 extends T1>(arg: T2) => T2;? Both vitest and TSD seem to use this pattern.

Would it make sense to take this a step further and implement in/out variance annotations with the satisfies operator?

@rsslldnphy
Copy link

rsslldnphy commented Apr 2, 2024

I think it is kind of like const assertions on generic parameters instead of doing as const in calling code. It makes a lot of sense to say that T should satisfy and not should extend - especially for excess property checks. Right now there is no way to pass a generic parameter that extends objects union (non discriminate) with excess property checks without satisfies from outside.

The use-case @Harpush describes is exactly what I'm facing now and led me to find this issue. Would be very happy if there was another way to do what I want but this is the challenge I'm facing:

type Foo = { a: number };

const foo = <T extends Foo>(x: T) => x;

foo({ a: 1, wrong: 2 }) // typechecks, but i don't want it to

foo({ a: 1, wrong: 2} satisfies Foo) // causes the type error i want

what I would really like to be able to write:

type Foo = { a: number };

const foo = <T satisfies Foo>(x: T) => x;

foo({ a: 1, wrong: 2 }) // causes the type error i want

Is this already possible in Typescript or would it need this feature to be implemented?

EDIT: Reading back the initial description of this issue, I am wondering whether what I'm describing is different enough to warrant a separate feature request, so I'll create one (and close it in favour of this one if it is considered a duplicate).

@mattidupre
Copy link

Adding on, this would help shimming utility types whose generics are not strict enough.

type FooBar = 'foo' | 'bar';
type Bar1 = Exclude<FooBar, 'baz'>; // will not error
type Bar2 = Exclude<FooBar, 'baz' satisfies FooBar>; // will error

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

10 participants