microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.11k stars 12.5k forks source link

Support `satisfies` in type declaration #52222

Open MaximSagan opened 1 year ago

MaximSagan commented 1 year ago

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:

📃 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 commented 1 year ago

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 commented 1 year ago

@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 commented 1 year ago

@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 commented 1 year ago

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 commented 1 year ago

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 commented 1 year ago

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;
}
Harpush commented 1 year ago

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 commented 1 year ago

@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 commented 11 months ago

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 commented 7 months ago

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 commented 6 months ago

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