-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
base: main
Are you sure you want to change the base?
Conversation
allow referencing an implict type parameter called "self" in type aliases, which gets instantiated with the type being related to when checking a type relation
This PR doesn't have any linked issues. Please open an issue that references this PR. From there we can discuss and prioritise. |
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)
Also can the bot create a playground for a failing PR as the build passes nonetheless? sounds too much of an ask haha |
about error messages, please take a look at #40468 throw types |
`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>>}'` |
There was a problem hiding this comment.
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.
`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>>}`> |
First off, thanks for the neat PR. I thought this deserved a bit of a deep dive. Regarding the implementation of 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
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 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 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 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
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. |
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. |
Thanks, glad you liked it.
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
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 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 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...
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 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 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
It is implemented but there might be some bugs. And yes it is intended to be an independent feature which works outside self types.
All the points you describe apply to signaling string literals and plain |
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 To give you a simple example the former declaration would produce two errors whereas the latter would produce only one (afaiu what 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. |
instead of using the ContainsSelf flag
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 And the CI should also be passing (except that one CodeQL check), I fixed those other trivial tests. |
Btw how do you only run a specific test case? eg I want to run |
I'm aware of that so I tried |
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. |
That's a good pointer, I tried a thing or two but nothing works...
Edit: I tried |
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. |
@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. |
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 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:
Also, on the topic of negated types, would your change allow something like this?
|
Yes, to make the change non-breaking.
I don't see how this is an issue at all.
That would work but check
I haven't read your suggestion (low on bandwidth for that) but it seems to me that you're missing that |
@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 const foo: Never<""> = { abc: 1 }; Checking this code results in |
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 = {}; |
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! |
TLDR: The following introduces and explains what this PR does, but to get a very rough idea see the
self-types-string-literal.ts
andself-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 notstring
. You'd use it like this...Now implementing this is rather simple...
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 doesquery
takes an argument of typenever
in the first place? Then they'd see the type declaraion ofquery
and then realise that what it really wants is a string literal.Also note that the type could resolve to different
never
s for different errors (here the error is only one) but it'd be impossible to see where did thenever
came from (eg from which branch), because allnever
s are the same.Simply put, the
never
s 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
...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
never
s.Problem B: It's not easy to compose the type
Imagine we have an existing piece of code that looks something like this...
Now one day we realise we want
securityRemark
to be a hard-coded string literal. Guess what the diff looks like...So you see the type
StringLiteral<Self>
is not easily composible, had it been a type without type parameters we'd have just replacedstring
withStringLiteral
.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 typeLineUp
that depends on typeOrder
. Now turningOrder
intoOrder<Self>
would mean that we need to composeOrder
inLineUp
in this same fashion turningLineUp
intoLineUp<Self>
and finallyFactory
intoFactory<Self>
And not to mention it also breaks existing code at places like...
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...With this we can now simply replace
string
withStringLiteral
in our existing types with the drawback being we'll have to uses
everywhere we're constructing a string literal...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 typeStringLiteral
.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...This PR roughly brings these three changes...
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).There now is a global intrinsic type alias called
Never
that resolves to anever
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'".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 (asunknown
type) or false (asnever
type)...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>
...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)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 theFlipValues
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...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...
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 andai
being the length ofi
-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, egself-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.