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

Feature: Self-Type-Checking Types #52088

Draft
wants to merge 31 commits into
base: main
Choose a base branch
from
Draft

Conversation

devanshj
Copy link

@devanshj devanshj commented Jan 3, 2023

TLDR: The following introduces and explains what this PR does, but to get a very rough idea see the self-types-string-literal.ts and self-types-case-insensitive.ts tests.

Self-Type-Checking Types

One of the most popular category of feature requests are new type constructs, some examples include exact types (#12936), json type (#1897), regex types (#41160), negated types (#4196), tuple-from-union type (#13298) and more.

But probably very few people realise you can implement almost any new type construct entirely in userland without having to make changes to the compiler. Of course it has some downsides which is precisely what this PR tackles.

But first let me show you how you'd implement a new type construct, what are the problems with it, what are the current workarounds to those problems and how this PR eliminates having to use these workarounds. For example let's implement the string literal type (#51513), it's an extremely simple and reasonable feature request (see the linked issue description), so it'll make a good example.

Implementing a new type construct userland

The idea is simple, we want a new type called StringLiteral which is subtype of all string literals but not string. You'd use it like this...

declare const query:
  (q: StringLiteral) => Promise<unknown>

query("test") // should compile
query(document.title) // should not compile
query(0) // should not compile

Now implementing this is rather simple...

type StringLiteral<Self> =
  Self extends string
    ? string extends Self
        ? never
        : Self
    : string

declare const query:
  <T>(q: StringLiteral<T>) => Promise<unknown>

query("test") 
// compiles

query(document.title)
// doesn't compile
// Argument of type 'string' is not assignable to parameter of type 'never'

query(0)
// doesn't compile
// Argument of type 'number' is not assignable to parameter of type 'string'

But there are two problems with this approach...

Problem A: No error messages

The first problem is the lack of error messages. "Argument of type 'string' is not assignable to parameter of type 'never'" doesn't tell what the error is, ie the user of query would ask themselves why does query takes an argument of type never in the first place? Then they'd see the type declaraion of query and then realise that what it really wants is a string literal.

Also note that the type could resolve to different nevers for different errors (here the error is only one) but it'd be impossible to see where did the never came from (eg from which branch), because all nevers are the same.

Simply put, the nevers implicitly carry an error given by the author but it doesn't reach the user.

Workaround for Problem A: String literals with error messages

The workaround for this problem is silly but simple, you just resolve to a string literal with an error message instead of never...

type StringLiteral<Self> =
  Self extends string
    ? string extends Self
        ? "Error: Not a string literal"
        : Self
    : string

declare const query:
  <T>(q: StringLiteral<T>) => Promise<unknown>

query(document.title)
// doesn't compile
// Argument of type 'string' is not assignable to parameter of type '"Error: Not a string literal"'

The error message "Argument of type 'string' is not assignable to parameter of type '"Error: Not a string literal"'" reads silly but it does the job putting across what the error is. And with this all errors also become distinct they are no longer nevers.

Problem B: It's not easy to compose the type

Imagine we have an existing piece of code that looks something like this...

type Order = 
  { id: number
  , meta: { securityRemark: string }
  }

declare const placeOrder:
  (o: Order) => void

Now one day we realise we want securityRemark to be a hard-coded string literal. Guess what the diff looks like...

type Order<Self> =
  { id: number
  , meta: { securityRemark: StringLiteral<Prop<Prop<Self, "meta">, "securityRemark">> }
  }

type Prop<T, K> =
  K extends keyof T ? T[K] : never

declare const placeOrder:
  <T extends Order<T>>(o: T) => void

So you see the type StringLiteral<Self> is not easily composible, had it been a type without type parameters we'd have just replaced string with StringLiteral.

And composing it like so is a nightmare because the type parameter propagates all the way to the root of the dependence tree. Ie imagine we had a type Factory that depends on type LineUp that depends on type Order. Now turning Order into Order<Self> would mean that we need to compose Order in LineUp in this same fashion turning LineUp into LineUp<Self> and finally Factory into Factory<Self>

And not to mention it also breaks existing code at places like...

let order: Order = {
//         ~~~~~
// Generic type 'Order' requires 1 type argument(s).
  id: 0,
  meta: { securityRemark: "test" }
}

Workaround for Problem B: Use an opaque type with a constructor

We can solve this problem of composition by using the type-parameter version of StringLiteral only while constructing the final non-type-parameterized type...

type StringLiteral = 
  & string
  & { readonly __tag: unique symbol }

type ParseStringLiteral<Self> =
  Self extends string
    ? string extends Self
        ? "Error: Not a string literal"
        : Self
    : string
  
declare const s:
  <T>(t: ParseStringLiteral<T>) => StringLiteral

With this we can now simply replace string with StringLiteral in our existing types with the drawback being we'll have to use s everywhere we're constructing a string literal...

type Order =
  { id: number
  , meta: { securityRemark: StringLiteral }
  }

let order: Order = {
  id: 0,
  meta: { securityRemark: s("test") }
}

Another less significant drawback being now the constructed type loses some information ie instead of being a string literal type "test" it's now the type StringLiteral.

How this PR solves Problem A and B

With this PR you can now write StringLiteral as the following which solves both problem A and problem B...

type StringLiteral =
  self extends string
    ? string extends self
        ? Never<`Type '${Print<self>}' is not assignable to type 'StringLiteral'`>
        : self
    : string

This PR roughly brings these three changes...

  1. Type aliases can now reference an implicit type parameter called self, which gets instantiated with the type-being-related-to while checking for a type relation (eg the subtype relation).

  2. There now is a global intrinsic type alias called Never that resolves to a never but takes a type argument of string literal or a tuple of string literals that gets displayed as error message or stacks of error messages instead of error messages like "Type 'X' is not assignable to 'never'".

  3. There now is a global instrinsic type alias called Print that takes a type as an argument and resolves to the print of that type as a string literal type.

What is this Foo<Self> pattern, why it works and why it's called self-type-checking?

A type is a set of values. A set of values is a propositional function that takes a value. Which implies a type is a propositional function that takes a value. Which is exactly what Foo<Self> types are: they take a value (as a type) and return true (as unknown type) or false (as never type)...

type NonZeroNumber<Self> =
  Self extends number
    ? Self extends 0
        ? never
        : unknown
    : never

declare const divide:
  <T extends [NonZeroNumber<T[0]>]>(a: number, ...b: T) => number

divide(1, 0 as 0)
// doesn't compile, argument of type 'number' is not assignable to parameter of type 'never'.

divide(1, 1 as 1)
// compiles

But there's a richer way to think about this... The compiler has a algorithm to check if a source type is subtype of target type and to produce errors if it's not. But given TypeScript's type system is turing-complete what if that subtype-checking algorithm is expressed in a type itself? So when a compiler wants to check the subtype relationship between a self-type-checking source type and a target type, it passes the target type to the self-type-checking type which in turns does the type-checking and produces required errors.

For example let's recreate mapped types without using TypeScript's mapped types, that is to say write our own type-checking algorithm for mapped types and not use the one in TypeScript compiler's source. Imagine we have a mapped type called FlipValues<T, K1, K2>...

type User =
  { name: string, age: number }

let t0: FlipValues<User, "name", "age"> = {
  name: "foo",
  age: "foo"
}
// doesn't compile
// Type 'string' is not assignable to type 'number'.
//   The expected type comes from property 'name' which is declared here on type 'FlipValues<User, "name", "age">'

let t1: FlipValues<User, "name", "age"> = {
  name: 0,
  age: "foo"
}
// compiles

type FlipValues<T, K1 extends keyof T, K2 extends keyof T> =
  { [K in keyof T]:
      K extends K1 ? T[K2] :
      K extends K2 ? T[K1] :
      T[K]
  }

This is how we create the FlipValues<T, K1, K2> without using mapped types... (No need to read too much into it, this is for the curious and advanced typescripters)

type User =
  { name: string, age: number }

let t0: FlipValues<User, "name", "age"> = {
  name: "foo",
  age: 0
}
// doesn't compile
// Type '{ name: string; age: number; }' is not assignable to type 'FlipValues<User, "name", "age">'
//   Type '{ name: string; age: number; }' is not assignable to type '{ age: string; name: number; }'
//     Types at property 'age' are incompatible
//       Type 'number' is not assignable to type 'string'

let t1: FlipValues<User, "name", "age"> = {
  name: 0,
  age: "foo"
}
// compiles

let t32: FlipValues<User, "name", "age"> = {
  name: 0
}

type FlipValues<T, K1 extends keyof T, K2 extends keyof T> =
  Mapped<
    keyof T, "FlipValues",  [T, K1, K2],
    `FlipValues<${Print<T>}, ${Print<K1>}, ${Print<K2>}>`
  >

interface Mappers<K, A>
  { FlipValues:
      A extends [infer T, infer K1, infer K2]
        ? K extends K1 ? T[K2 & keyof T] :
          K extends K2 ? T[K1 & keyof T] :
          T[K & keyof T]
        : never
  }

/**
 * @param K key of new type
 * @param F mapper identifier
 * @param A extra argument to mapper
 * @param N name of new type
 */
type Mapped<K, F, A, N> =
  MappedError<K, F, A, N, self> extends infer E extends string | string[]
    ? [E] extends [never] ? self : Never<E>
    : never

type MappedError<K, F, A, N, Self, KCopy = K> =
  UShift<
    K extends unknown
      ? K extends keyof Self
          ? Get<Mappers<K, A>, F> extends infer Fka // F<K, A>
              ? Self[K] extends Fka
                  ? never
                  : [ `Type '${Print<Self>}' is not assignable to type '${N & string}'`
                    , `Type '${Print<Self>}' is not assignable to type '${PrintMapped<KCopy, F, A>}'`
                    , `Types at property '${PrintKey<K>}' are incompatible`
                    , `Type '${Print<Self[K]>}' is not assignable to type '${Print<Fka>}'`
                    ]
              : never
          : [ `Type '${Print<Self>}' is not assignable to type '${N & string}'`
            , `Type '${Print<Self>}' is not assignable to type '${PrintMapped<KCopy, F, A>}'`
            , `Property '${PrintKey<K>}' is required in target type but missing in source type`
            ]
      : never
  >

interface Mappers<K, A> {}

type PrintMapped<K, F, A> = 
  `{ ${Join<
    K extends unknown
      ? `${PrintKey<K>}: ${Print<Get<Mappers<K, A>, F>>};`
      : never,
    " "
  >} }`

type Join<T extends string, D extends string> =
  UIsUnit<T> extends true ? `${T}` :
  `${Cast<UShift<T>, string | number>}${D}${Join<UShifted<T>, D>}`

type UShift<U> =
  UToIntersection<U extends unknown ? (x: U) => void : never> extends (_: infer H) => void
    ? H
    : never

type UToIntersection<T> =
  (T extends unknown ? (_: T) => void : never) extends ((_: infer I) => void)
    ? I
    : never

type UShifted<U> =
  Exclude<U, UShift<U>>

type UIsUnit<U> =
  [UShifted<U>] extends [never] ? true : false

type Cast<T, U> =
  T extends U ? T : U

type PrintKey<K> =
  K extends symbol ? Print<K> :
  K extends string ? K :
  K extends number ? K :
  never

type Get<T, K> =
  K extends keyof T ? T[K] : never

So you see the type-checking algorithm is in the type itself ie it type-checks on it's own, hence they're called self-type-checking types.

A simple yet powerful example

I chose the StringLiteral example to give an introduction with because it was the simplest and later the FlipValues mapped type without using mapped types example to show how powerful these types are, but now let me give a simple yet powerful example by creating a type for case-insenstive string literals...

type CaseInsensitive<T extends string> =
  self extends string
    ? Lowercase<self> extends Lowercase<T>
        ? self
        : Never<[
          `Type '${Print<self>}' is not assignable to type 'CaseInsensitive<${Print<T>}>'`,
          `Type 'Lowercase<${Print<self>}>' is not assignable to 'Lowercase<${Print<T>}>'`,
          `Type '${Print<Lowercase<self>>}' is not assignable to '${Print<Lowercase<T>>}'`
        ]>
    : T

declare const setHeader: 
  (key: CaseInsensitive<"Set-Cookie" | "Accept">, value: string) => void

setHeader("Set-Cookie", "ok")
setHeader("Accept", "ok")
setHeader("SET-COOKIE", "ok")
setHeader("sEt-cOoKiE", "stop writing headers like this but ok")
setHeader("Acept", "nah this has a typo")
// Type '"Acept"' is not assignable to type 'CaseInsensitive<"Set-Cookie" | "Accept">'
//   Type 'Lowercase<"Acept">' is not assignable to 'Lowercase<"Set-Cookie" | "Accept">'
//     Type '"acept"' is not assignable to '"set-cookie" | "accept"'

In mere 10 simple lines of code you can create a new type construct which gives a more correct and richer type to our setHeader function.

This example also shows how creating a new type construct for a type can be a gazillion times more peformant than expressing it with existing type constructs like this...

type CaseInsensitive<S extends string> =
  S extends `${infer H}${infer T}`
    ? `${Lowercase<H>}${CaseInsensitive<T>}` | `${Uppercase<H>}${CaseInsensitive<T>}`
    : S

This produces a union of 2^a0 + 2^a1 + ... + 2^an string literals (n being the number of constituent string literals in the type parameter and ai being the length of i-th string literal). Not only this is slow enough to not to be used, in a limiting case TypeScript itself won't allow using it by giving a "Expression produces a union type that is too complex to represent" error.

Not to mention the error message is readable, expressive and complete.

There are several other interesting example and type constructs that you can find in the tests. For example there are tests that implement exact types (#12936), json type (#1897), and tuple-from-union type (#13298).

Please note that some tests are purposely creative and extreme, the users are not expected to write such complex types, eg self-types-color.ts, self-types-json.ts. And some tests reflect realistic simple usages, eg self-types-string-literal.ts, self-types-case-insensitive.ts, self-types-json-simple.ts, self-types-probability.ts, self-types-non-zero-number.ts, self-types-state-machine.ts.

Future

The Foo<Self> pattern is an organic discovery and not an invention sprung out of thin air. It's something that works. And its working can be cleany traced back to theory. So in some sense all that this PR does is that it facilitates the usage of this natural, working, backed-by-theory pattern.

I've written this PR for fun and as an experiment, so no pressure to merge this in, but it's something I'd like the TypeScript team to consider. If the TypeScript team likes the idea then we can take this PR further and fill in the remaining gaps.

Thanks for reading.

@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Jan 3, 2023
@typescript-bot
Copy link
Collaborator

This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise.

src/compiler/checker.ts Fixed Show resolved Hide resolved
@devanshj
Copy link
Author

devanshj commented Jan 3, 2023

There are some tests that need updating. Most of them are trivial eg addition of a new global completion called "self". So I'll fix the tests soon. Meanwhile you can test the PR in two ways locally... (instructions for those not whose are familiar)

  • hereby runtests -t self-types*
  • hereby min and then use locally built compiler for intellisense in vscode by hovering "{}" next to bottom right "TypeScript" then "Select Version" and then the one that has path as "built/local"

Also can the bot create a playground for a failing PR as the build passes nonetheless? sounds too much of an ask haha

@devanshj devanshj marked this pull request as draft January 4, 2023 00:56
@Jack-Works
Copy link
Contributor

about error messages, please take a look at #40468 throw types

Comment on lines +14 to +16
`Type '${Print<self>}' is not assignable to type 'CaseInsensitive<${Print<T>}>'`,
`Type 'Lowercase<${Print<self>}>' is not assignable to 'Lowercase<${Print<T>}>'`,
`Type '${Print<Lowercase<self>>}' is not assignable to '${Print<Lowercase<T>>}'`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might prevent from different error message styles like "Can not assign {} to {}", "Must be {}, got {}" etc.

Suggested change
`Type '${Print<self>}' is not assignable to type 'CaseInsensitive<${Print<T>}>'`,
`Type 'Lowercase<${Print<self>}>' is not assignable to 'Lowercase<${Print<T>}>'`,
`Type '${Print<Lowercase<self>>}' is not assignable to '${Print<Lowercase<T>>}'`
NotAssignableErorrMessage<`${Print<self>}`, `CaseInsensitive<${Print<T>}>`>,
NotAssignableErorrMessage<`Lowercase<${Print<self>}>`, `Lowercase<${Print<T>}>`,
NotAssignableErorrMessage<`${Print<Lowercase<self>>}`, `${Print<Lowercase<T>>}`>

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jan 4, 2023

First off, thanks for the neat PR. I thought this deserved a bit of a deep dive.

Regarding the implementation of self - in general it doesn't "work" to try to create new assignability rules by, at the top level, checking for special properties of the target type, as happens here. This gets you correct top-level behavior, of course, but higher-level stuff doesn't work like expected:

type Box<T> = { value: T };
type Fooish = CaseInsensitive<"Foo">;
const x1: CaseInsensitive<"Foo"> = "FOO"; // OK
const x2: Fooish = "FOO"; // OK due to alias resolution
const x3: Box<CaseInsensitive<"Foo">> = { value: "FOO" }; // OK
const x4: Box<Fooish> = { value: "FOO" }; // Fails
tbi/a.ts:18:27 - error TS2322: Type '"FOO"' is not assignable to type 'CaseInsensitive<"Foo">'.

18 const x4: Box<Fooish> = { value: "FOO" }; // Fails
                             ~~~~~

A similar problem occurs if you just try to do a refactor on your example:

type HeaderNames = CaseInsensitive<"Set-Cookie" | "Accept">;
declare const setHeader: (key: HeaderNames, value: string) => void
// all invocations fail

I was kind of surprised this didn't work, not sure what's going wrong (I was trying to demonstrate some other stuff but got stuck here):

type DistributeCaseInsensitive<T extends string> = T extends unknown ? CaseInsensitive<T> : never;
// Fails, should be OK
let m: DistributeCaseInsensitive<"A" | "B"> = 'a'

Regarding the feature itself... using self types that don't evince higher-order relational behavior creates very large completeness gaps that would be immediately apparent:

type AnyString<T> = self extends string ? T : never;
function foo<T extends string>(a: CaseInsensitive<T>) {
  let m: AnyString<T> = a;
  //  ~
}

a.ts:15:7 - error TS2322: Type 'CaseInsensitive<T>' is not assignable to type 'AnyString<T>'.
  Type 'T | (Lowercase<self> extends Lowercase<T> ? self : never)' is not assignable to type 'AnyString<T> | AnyString<T>'.
    Type 'T' is not assignable to type 'AnyString<T> | AnyString<T>'.
      Type 'string' is not assignable to type 'AnyString<T> | AnyString<T>'.

The idea of a type that can do its own validation is interesting and worth thinking about how to manifest, but I think in practice the problem is that people generally expect types to evince higher-order behavior, especially if they've encoded the rules of those types in the type system's syntax itself. A good example that comes to mind is the relation between T and Partial<T> -- the assignability relationship implied by those two types doesn't just "happen" if you make a naive Partial implementation, you actually have to do the work to identify that it is a subtype, and until you do this, it seems very very buggy that it doesn't "work".

We see this all the time as people try to do relations involving aliased higher-order operations which don't produce any higher-order behavior, e.g. they expect that Omit<T, K0> should be assignable to Omit<T, K1> as long as K1 was produced by a named operation from K0 that would imply the subtyping (recent example: #51972). But they're just higher-order types and we can't relate them in those terms, even though it "looks like can" because those behaviors were named in terms of type aliases.

Taking this problem to the infinite degree by allowing you to, as described, "write programs", makes the problem infinitely worse, because talking about whether one program accepts a superset or subset of values of some other program is just plainly intractable beyond the trivial cases. It's very powerful to talk about the behavior of a single arbitrary program, but it necessarily involves giving up the ability to reason about the relation of one program's inputs to another's.

Moving on, there some mundane practical reasons we haven't invested in a "signaling never" (presuming that such a construct would be allowed to exist outside of a self type, which AFAICT from experimentation is not implemented in this PR):

  • If widely adopted, it'd be bad for performance
  • Error messages from user code can't be localized, and a lot of people actually do use localized error messages
  • Heuristics for reporting the "best" error generally stop working
  • Signaling never can't be used to tell you why an overload wasn't selected, for example -- it only works in error cases
  • How exactly they behave when e.g. distributed as part of a union is confusing at best to think about

I think all of those objections are, well, fairly mundane and likely something that could be overcome in due time, but in each of the use cases where they seem to come up it's been a case where other solutions either elsewhere in the type system or just via simple signaling literals as described in the OP seem to be a better trade-off.

@fatcerberus
Copy link

fatcerberus commented Jan 4, 2023

Taking this problem to the infinite degree by allowing you to, as described, "write programs", makes the problem infinitely worse, because talking about whether one program accepts a superset or subset of values of some other program is just plainly intractable beyond the trivial cases. It's very powerful to talk about the behavior of a single arbitrary program, but it necessarily involves giving up the ability to reason about the relation of one program's inputs to another's.

And this is just the tip of the iceberg, before things like the halting problem rear their ugly heads--which is unfortunately relevant because the type system is Turing-complete.

@devanshj
Copy link
Author

devanshj commented Jan 4, 2023

First off, thanks for the neat PR. I thought this deserved a bit of a deep dive.

Thanks, glad you liked it.

Regarding the implementation of self - in general it doesn't "work" to try to create new assignability rules by, at the top level, checking for special properties of the target type, as happens here. This gets you correct top-level behavior, of course, but higher-level stuff doesn't work like expected

It's true there's gap in implementation but it's not the one you mention. Consider this even more complex case...

type Foo<T> = 
  T extends "foo"
    ? CaseInsensitive<"bar">
    : never

declare const f:
  <T extends string>(x: T, y: Foo<T>) => void

f("foo", "BAR")
//       ~~~~~
// Argument of type '"BAR"' is not assignable to parameter of type 'CaseInsensitive<"bar">'.

This too doesn't compile when it should, but we can see that at the end of the day we're still checking "BAR" with CaseInsensitive<"bar"> (same is with your snippets too). The question is how come the control doesn't reach the change made in this PR when this assignability is checked? Or if the TypeFlags.ContainsSelf gets lost somewhere? There's a bug here but the approach of having a change "at the top level" checking seems to be right.

using self types that don't evince higher-order relational behavior creates very large completeness gaps that would be immediately apparent

As you yourself mention this is not a self types issue but rather the issue of the compiler not being smart enough to check subtype relationships of generic constructs. So the completeness gaps users would see if they do something complex are the same completeness gaps that they see today itself elsewhere. What needs to be checked if the completeness gaps come up way more than desired.

It's true that if a type construct is implemented in the compiler as opposed to in a type it can do more and go out of the way and make things more complete, but self types are not intended to create complex type constructs in the first place.

The example you mention though seems to be working correctly, the error elaboration seems to stop at Type 'string' is not assignable to type 'AnyString<T> | AnyString<T>' but we can go futher manually...

type AnyString<T> = self extends string ? T : never;
function foo<T extends string>(a: CaseInsensitive<T>) {
  let m: AnyString<T> = a;
  //  ~
  // Type 'CaseInsensitive<T>' is not assignable to type 'AnyString<T>'.
  //   Type 'T | (Lowercase<self> extends Lowercase<T> ? self : never)' is not assignable to type 'AnyString<T> | AnyString<T>'.
  //     Type 'T' is not assignable to type 'AnyString<T> | AnyString<T>'.
  //       Type 'string' is not assignable to type 'AnyString<T> | AnyString<T>'.

  let n: AnyString<T> | AnyString<T> = {} as string
  // Type 'string' is not assignable to type 'T'.
  //   'string' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string'.
}

So the error at m is right. Imagine if T is "FOO" and a is "foo", in that case AnyString<T> becomes "FOO" and a is not assignable to m as "foo" is not assignable to "FOO". Had AnyString been self extends string ? self : never the assignment would work, except it does't work because of the same implementation bug we saw above, but again we can check it manually...

type AnyString = self extends string ? self : never;
function foo<T extends string>(a: CaseInsensitive<T>) {
  let m: AnyString = a;
  //  ~
  // Type 'CaseInsensitive<T>' is not assignable to type 'AnyString'.
  //   Type 'T | (Lowercase<self> extends Lowercase<T> ? self : never)' is not assignable to type 'AnyString | AnyString'.
  //     Type 'T' is not assignable to type 'AnyString | AnyString'.
  //       Type 'string' is not assignable to type 'AnyString | AnyString'

  let n: AnyString | AnyString = {} as string
  // compiles
  // and the other branch ie `Lowercase<self> extends Lowercase<T> ? self : never`
  // would probably be assignable to `string` too as we've made the check that
  // `self extends string` in parent so the `self` here would be `self & string`
}

But more importantly in this snippet we're comparing two different self types, and I haven't made clear what should happen in such case, which I do in the next point...

Taking this problem to the infinite degree by allowing you to, as described, "write programs", makes the problem infinitely worse, because talking about whether one program accepts a superset or subset of values of some other program is just plainly intractable beyond the trivial cases. It's very powerful to talk about the behavior of a single arbitrary program, but it necessarily involves giving up the ability to reason about the relation of one program's inputs to another's.

This isn't a problem. We're talking about a case where two self types are being related. Two self types relate with each other in the same way two branded types do. Meaning most of the times they aren't a subtype of each other and that's how they're indended to be. Let me explain this a bit.

Imagine you have the following code...

type Probability =
  self extends number
    ? IsProbability<self> extends true
      ? self
      : Never<`Type '${Print<self>}' is not assignable to type 'Probability'`>
    : number

type IsProbability<T extends number> =
  `${T}` extends `${infer H}${infer R}`
      ? H extends "0" ? true :
        H extends "1" ? R extends "" ? true : false :
        false
      : false

type ZeroPointFive =
  self extends 0.5 ? self : never

declare let a: Probability
declare let b: ZeroPointFive
a = b
// Type 'ZeroPointFive' is not assignable to type 'Probability'.

One would consider this a bug as ZeroPointFive should be a subtype of Probability. But this is an accepted incompleteness, and it is the same incompleteness if you were to implement the workaround I show in the OP...

type Probability = number & { readonly __tag: unique symbol }
type ZeroPointFive = 0.5 & { readonly __tag: unique symbol }

declare let a: Probability
declare let b: ZeroPointFive
a = b
// Type 'ZeroPointFive' is not assignable to type 'Probability'.

So ZeroPointFive not being a subtype of Probability is just the status quo, self types don't bring any improvements in this area.

Some times though two self types might have the correct subtype relationship, eg...

type A = self extends "a" ? self : never
type AB = self extends "a" | "b" ? self : never
let x: AB = {} as A // compiles

This is because we don't go out of our way to make two self types nominal, if we can find a subtype relationship we remain true to it.

And this is another good way to think of self types, they're branded types except they remove the pain of asserting a hardcoded value that we know is a subtype. That is to say imagine if we're working with probabilities, today we'd implement the above branded Probability type, but here and there we have some 0, 1, 0.5 hardcoded which have to be asserted 0 as Probability, now enter self types, we make Probability a self type and all those assertions go away.

AFAICT from experimentation is not implemented in this PR

It is implemented but there might be some bugs. And yes it is intended to be an independent feature which works outside self types.

I think all of those objections are, well, fairly mundane and likely something that could be overcome in due time, but in each of the use cases where they seem to come up it's been a case where other solutions either elsewhere in the type system or just via simple signaling literals as described in the OP seem to be a better trade-off.

All the points you describe apply to signaling string literals and plain never as well. A Never<"foo"> is as bad as "foo" or never except we can improve some points because we have the information that it signals an error, in case of "foo" or never it's totally indistinguishable from a legitimate string literal type or a legitimate never type.

@devanshj
Copy link
Author

devanshj commented Jan 4, 2023

about error messages, please take a look at #40468 throw types

What is happening here is totally unrelated to #40468. We're not throwing here, we're just retuning the type equivalent of logical false in the propositional function that a self type is. The never here is not a "workaround" we actually want a never, and Never literally constructs a never just with additional meta information of a message. And moreover having an effect in a pure functional langauge is semantically incoherent and problematic.

To give you a simple example the former declaration would produce two errors whereas the latter would produce only one (afaiu what throw is meant to be)...

let x: { a: Never<"a">, b: Never<"b"> } = { a: 0, b: 0 }
let y: { a: throw "a", b: throw "b" } = { a: 0, b: 0 }

Again this is just a simple example, it's not representative of the huge philosophical gap there is between both PRs. There could be some cross pollination of ideas but that's a secondary thing.

@devanshj
Copy link
Author

devanshj commented Jan 6, 2023

Or if the TypeFlags.ContainsSelf gets lost somewhere?

As I guessed this is what was happening. It was too much hassle to juggle and preserve that flag around so I created a new type construct instead, which also made the implementation little more simple and clean. So the bugs you highlighted are fixed, see self-types-ryan.ts.

And the CI should also be passing (except that one CodeQL check), I fixed those other trivial tests.

@devanshj
Copy link
Author

devanshj commented Jan 7, 2023

Btw how do you only run a specific test case? eg I want to run src/testRunner/unittests/publicApi.ts only to generate the new baseline for public api and not the whole test suite. Is there a way to do that?

@Andarist
Copy link
Contributor

Andarist commented Jan 7, 2023

@devanshj the available options are listed here. I usually just run this:

hereby runtests --tests=myTest --no-lint

@devanshj
Copy link
Author

devanshj commented Jan 7, 2023

I'm aware of that so I tried hereby runtests -t publicApi.ts (I guess that does the same thing) but apparently it doesn't work, ie it results into empty test suite. Maybe the -t filter only works for compiler case tests idk because hereby runtests -t self-types* works as expected.

@Andarist
Copy link
Contributor

Andarist commented Jan 7, 2023

It seems that unit tests are somewhat special and they might run conditionally. I found some things related to that in this file - but I wasn't able to quickly craft a command that would do what you want here.

@devanshj
Copy link
Author

devanshj commented Jan 7, 2023

That's a good pointer, I tried a thing or two but nothing works...

  • hereby runtests -t publicApi.ts --runUnitTests
  • echo '{ "test": ["publicApi.ts"], "runUnitTests": true }' > mytest.config && hereby runtests --config mytest.config.

Edit: I tried hereby runtests --runners unittest that works but then again it runs all unit tests and doesn't work with -t publicApi.ts

@devanshj
Copy link
Author

devanshj commented Jan 8, 2023

Btw the CI is passing, so can I get a playground build for this? That'll make it easier for everyone to play and test this PR.

@devanshj
Copy link
Author

devanshj commented Jan 8, 2023

@ahejlsberg have you seen this PR? I wonder if you like it? :P — I understand Ryan represents the whole team and I can discuss the nitty-gritties with him but I was just curious to know your initial reaction.

@Andarist Andarist mentioned this pull request Apr 7, 2023
@henrikeliq
Copy link

henrikeliq commented Apr 8, 2023

Super cool stuff.

I made a suggestion recently here that is very similar to this.

The main thing that differs between my suggestion and this is that I thought it would be good to use infer in the start of the type definition, i.e. type a = infer Self extends string ... instead of type a = self extends string ....

I'm pretty sure I overestimated how big of an issue name collisions with user-code would be using your approach, but it's a bit tricky to me. What are your thoughts about this?

More specifically these scenarios:

  1. There exists a type with name self already.
    • I guess we won't care much about this because of the convention to write types in pascal-case, but does user-defined types takes precedence?
  2. There exists a variable named self already.
    • I don't think this will be a big issue because of the different namespaces that variables and types have, but when writing tests for this it's probably a good idea to make sure that typeof self doesn't produce errors if there's a user variable called self.
  3. There exists a generic type parameter called self already.
    • Same as (1)

Also, on the topic of negated types, would your change allow something like this?

export type NotFunction = Exclude<self, (...args: any) => any>

@devanshj
Copy link
Author

devanshj commented Apr 9, 2023

does user-defined types takes precedence?

Yes, to make the change non-breaking.

I don't think this will be a big issue

I don't see how this is an issue at all.

Also, on the topic of negated types, would your change allow something like this?

That would work but check tests/cases/compiler/self-types-not.ts for better ideas on how to implement and write not types.

The main thing that differs between my suggestion and this is that I thought it would be good to use infer in the start of the type definition, i.e. type a = infer Self extends string ... instead of type a = self extends string ....

I haven't read your suggestion (low on bandwidth for that) but it seems to me that you're missing that self can appear anywhere in the definition not just in the beginning, in fact it has nothing to do with infer or inference. I think I've described quite well in the PR description what self is, or rather what it references or gets instantiated with.

@Peeja
Copy link
Contributor

Peeja commented May 4, 2023

@devanshj I love this proposal so much. Playing with it a bit locally, I seem to have hit a gap in the implementation. It looks like Never doesn't handle object types gracefully:

const foo: Never<""> = { abc: 1 };

Checking this code results in RangeError: Maximum call stack size exceeded

@Peeja
Copy link
Contributor

Peeja commented May 4, 2023

Another interesting hiccup:

// By normal `extends` semantics, the LHS of `extends` can have more properties than the RHS:
type NormalExtends = {
  a: 1;
  b: 2;
} extends {
  a: 1;
}
  ? true
  : false;

// ✅
const a: NormalExtends = true;

/////

// Yet this doesn't hold through `self`:
type SelfTyped = self extends {
  a: 1;
}
  ? self
  : never;

// ❌ The extra `b` makes this fail.
const b: SelfTyped = {
  a: 1,
  b: 2,
};

// ✅ It passes if if has exactly the property on the RHS of `extends`.
const c: SelfTyped = {
  a: 1,
};

// ❌ Missing the `a` is (correctly) a failure.
const d: SelfTyped = {};

/////

// Notably, though, if the RHS is an *empty* object, these all pass.
type SelfTypedWithEmpty = self extends {} ? self : never;

// ✅
const b2: SelfTypedWithEmpty = {
  a: 1,
  b: 2,
};

// ✅
const c2: SelfTypedWithEmpty = {
  a: 1,
};

// ✅
const d2: SelfTypedWithEmpty = {};

@devanshj
Copy link
Author

Hey @Peeja, I'm not actively working on this PR so I might not be able to reply. Although feel free to keep posting these bugs/anomalies. And thanks for liking the PR!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

9 participants