microsoft / TypeScript

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

Mapped conditional types #12424

Closed matt-hensley closed 6 years ago

matt-hensley commented 7 years ago

12114 added mapped types, including recursive mapped types. But as pointed out by @ahejlsberg

Note, however, that such types aren't particularly useful without some form of conditional type that makes it possible to limit the recursion to selected kinds of types.

type primitive = string | number | boolean | undefined | null;
type DeepReadonly<T> = T extends primitive ? T : DeepReadonlyObject<T>;
type DeepReadonlyObject<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>;
};

I couldn't find an existing issue with a feature request.

Conditional mapping would greatly improve the ergonomics of libraries like Immutable.js.

Artazor commented 7 years ago

Surely it should be implemented. However, need to find an appropriate syntax. There is possible clash with other proposal: #4890

gentoo90 commented 7 years ago

Another possible use case is typeguarding Knockout.js mappings, which needs choosing between KnockoutObservable and KnockoutObservableArray.

In

interface Item {
    id: number;
    name: string;
    subitems: string[];
}

type KnockedOut<T> = T extends Array<U> ? KnockoutObservableArray<U> : KnockoutObservable<T>;

type KnockedOutObj<T> = {
    [P in keyof Item]: KnockedOut<Item[P]>;
}

type KoItem = KnockedOutObj<Item>

KoItem should be expanded to

type KoItem = {
    id: KnockoutObservable<number>;
    name: KnockoutObservable<string>;
    subitems: KnockoutObservableArray<string>;
}
rotemdan commented 7 years ago

Hi, I was just reading "GADTs for dummies" (which might be helpful for anyone interested in this issue) where GADT = "Generalized Algebraic Data Type". Although I'm not quite really there in getting a full understanding of the concept, it did occur to me that what is described here can alternatively be elegantly expressed through a form of "overloading", or more specifically, pattern matching, over type constructors:

type Primitive = string | number | boolean | undefined | null;

type DeepReadonly<T extends Primitive> = T;
type DeepReadonly<T> = { readonly [P in keyof T]: DeepReadonly<T[P]>; };

The idea is that this works just like regular pattern matching: given any type T the first pattern (T extends Primitive) is tested. If the type matches the constraint, then it is resolved, if not, it continues to the next pattern (<T>). Since there is no constraint on the second one, it acts similarly to otherwise or default and would accept anything that doesn't match the previous ones (Note the order of statements matters here: similarly to class method overloading it probably must be enforced that overloaded type declarations of similar identifiers strictly follow each other, to prevent accidental redefinition of types)

One thing that differs from the GHC extension syntax is that in the example I gave the type constructor overloads are anonymous. The reason they are named in Haskell, I believe, is to allow functions to directly switch or pattern match over different named constructors of the type. I believe this is not relevant here.

There's much more to this subject, I guess. It might takes some time for me to get an adequate understanding of GADTs and the implications of applying them here.

rotemdan commented 7 years ago

I'll try to give examples of other applications of such types:

Let's say I want to define a function that takes a value of any primitive type and returns the "zero" value corresponding to that value's type:

function zeroOf(val) {
    switch (typeof val) {
        case "number":
            return 0;
        case "string":
            return "";
        case "boolean":
            return false;
        default:
            throw new TypeError("The given value's type is not supported by zeroOf");
    }
}

How would you type this function? The best current solution offered by typescript is to use the union number | string | boolean as paramter type and and the union 0 | "" | false as return type:

(Edit: yes this can be improved to use overloaded method signature, but the actual signature would still look like this, I've explained the difference in another edit below)

function zeroOf(val: number | string | boolean): 0 | "" | false {
    // ...
}

However, the problem is that this doesn't allow "matching" a type argument to the correct member of the union.

But what if it was possible to define "overloaded" type aliases? you could very naturally define:

type ZeroOf<T extends number> = 0; 
type ZeroOf<T extends string> = "";
type ZeroOf<T extends boolean> = false;
type ZeroOf<T> = never;

function zeroOf(readonly val: number | string | boolean): ZeroOf<typeof val> {
    switch (typeof val) {
        case "number": // typeof val is narrowed to number. ZeroOf<number> resolves to 0!
            return 0;
        case "string": // typeof val is narrowed to string. ZeroOf<string> resolves to ""!
            return "";
        case "boolean": // typeof val is narrowed to boolean. ZeroOf<boolean> resolves to false!
            return false;
        default: // typeof val is narrowed to never
                         // ZeroOf<never> (or any other remaining type) also resolves to never!
            throw new TypeError("The given value's type is not supported by zeroOf");
    }
}

The combination of the overloaded type alias and literal types is so expressive here to the point where the signature almost "forces" a correct implementation of the function!

Here's another example, of an evaluator function. The function takes an expression object and returns an evaluation of it. The result could be either a number, a string, or a boolean. This would be the "normal" way to describe this:

function eval(expression: NumberExpr | StringExpr | AdditionExpr | EqualityExpr): number | string | boolean {
    if (isNumberExpr(expression) || isStringExpr(expression)) { // These could be user defined guards
        return expression.terms[0];
    } else if (isAdditionExpr(expression)) { // This could be a user defined guard
        return eval(expression.terms[0]) + eval(expression.terms[1]);
    } else if (isEqualityExpr(expression)) { // This could be a user defined guard
        return eval(expression.terms[0]) === eval(expression.terms[1]);
    }
}

What if it was possible to represent the exact expected mapping between the given expression type and the resulting evaluated return type, in a way where the correct return type could also be enforced within the body of the function?

(Edit: note this is somewhat comparable to an overloaded method signature, but more powerful: it allows the return type to be expressed clearly as a type, guarded on, checked and reused in the body of the function or outside of it. So it makes the mapping more "explicit" and encodes it as a well-defined type. Another difference is that this can also be used with anonymous functions.)

type EvalResultType<T extends NumberExpr> = number;
type EvalResultType<T extends StringExpr> = string;
type EvalResultType<T extends AdditionExpr> = number;
type EvalResultType<T extends EqualityExpr> = boolean;

function eval(readonly expression: NumberExpr | StringExpr | AdditionExpr | EqualityExpr): EvalResultType<typeof expression> {
    if (isNumberExpr(expression) || isStringExpr(expression)) { // These could be user defined guards
        return expression.terms[0];
    } else if (isAdditionExpr(expression)) { // This could be a user defined guard
        return eval(expression.terms[0]) + eval(expression.terms[1]);
    } else if (isEqualityExpr(expression)) { // This could be a user defined guard
        return eval(expression.terms[0]) === eval(expression.terms[1]);
    }
}

Edit: Seems like these examples are not "convincing" enough in the context of this language, though they are the ones that are classically used with GADTs. Perhaps I've tried hard to adapt them to the limitations of Typescript's generics and they turned out too "weak". I'll try to find better ones..

dead-claudia commented 7 years ago

@rotemdan

This might go well with #12885. In particular, most of your examples would be redundant:

function eval(readonly expression: NumberExpr): number;
function eval(readonly expression: StringExpr): string;
function eval(readonly expression: AdditionExpr): number;
function eval(readonly expression: EqualityExpr): boolean;
function eval(readonly expression: NumberExpr | StringExpr | AdditionExpr | EqualityExpr): string | number | boolean {
    if (isNumberExpr(expression) || isStringExpr(expression)) { // These could be user defined guards
        return expression.terms[0];
    } else if (isAdditionExpr(expression)) { // This could be a user defined guard
        return eval(expression.terms[0]) + eval(expression.terms[1]);
    } else if (isEqualityExpr(expression)) { // This could be a user defined guard
        return eval(expression.terms[0]) === eval(expression.terms[1]);
    }
}

This proposal could partially solve my function-related issue, though:

interface Original {
    [key: string]: (...args: any[]) => any
}

interface Wrapped {
    [key: string]: (...args: any[]) => Promise<any>
}

// Partial fix - need a guard in the mapped `P` type here...
type Export<R extends Promise<any>, T extends (...args: any[]) => R> = T
type Export<R, T extends (...args: any[]) => R> = (...args: any[]) => Promise<R>

interface Mapped<T extends Original> {
    [P in keyof T]: Export<T[P]>
}
rotemdan commented 7 years ago

@isiahmeadows

I've read your proposal but wasn't 100% sure if that what was intended. I'm aware that a non-recursive use of this feature with functions could be seen as somewhat similar to method overloading (of the form Typescript supports). The main difference is that the return values (or possibly also argument values whose type is dependent on other argument types) would have a well-defined type that is natively expressible in the language, rather than just being implicitly narrowed as a compiler "feature".

Another advantage I haven't mentioned yet is that the return type could be expressed even if the argument itself is a union (or maybe a constrained generic type as well?) and could be propagated back to the caller chain:

function func1(const a: string): number;
function func1(const a: number): boolean;
function func1(const a: string | number): number | boolean {
  if (typeof a === "string") 
     return someString;  // Assume the expected return type is implicitly narrowed here to number.
  else if (typeof a === "number")
     return someBoolean; // Assume the expected return type is implicitly narrowed here to boolean.
}

function func2(const b: string | number) { // 
  const x = func1(b); // How would the type of x be represented?

  if (typeof b === "number") {
      x; // Could x be narrowed to boolean here?
  }
}

In general I find the idea that a type could describe a detailed relationship between some set of inputs and outputs very powerful, and surprisingly natural. In its core, isn't that what programming is all about? If a type could, for example, capture more specific details about the mapping between say, different ranges, or sub-classes of inputs to the expected ranges/sub-classes of outputs, and those can be enforced by the compiler, it would mean mean that the compiler could effectively "prove" correctness of some aspects of the program.

Perhaps encoding these relationships is not actually the most difficult aspect, but "proving" them is. I've read a bit about languages like Agda and Idris that feature dependent types but haven't really got deeply into that. It would be interesting to at least find some very limited examples of how (enforceable) dependent types would look like in Typescript. I understand that it may be significantly more challenging to implement them over impure languages like Javascript though.

dead-claudia commented 7 years ago

It kind of helps that in languages like Idris and Agda, pattern matching is practically the only way to actually conditionally test things (if-then-else are language level for them).

But yes, that declarative kind of mechanism is useful. It's why, in my limited experience, it's easier to read complex Idris types than equivalent Haskell or even TypeScript types. But I'm not quite as sold on how much that actually fits in with the rest of TypeScript stylistically. Programming language design is just as much an art form as it is a logical point of research and practicality.

On Tue, Dec 27, 2016, 11:42 Rotem Dan notifications@github.com wrote:

@isiahmeadows https://github.com/isiahmeadows

I've read your proposal but wasn't 100% sure if that what was intended. I'm aware that a non-recursive use of this feature with functions could be seen as somewhat similar to method overloading (of the form Typescript supports). The main difference is that the return values (or possibly also argument values whose type is dependent on other argument types) would have a well-defined type that is natively expressible in the language, rather than just being implicitly narrowed as a compiler "feature".

Another advantage I haven't mentioned yet is that the return type could be expressed even if the argument itself is a union (or maybe a constrained generic type as well?) and could be propagated back to the caller chain:

function func1(const a: string): number;function func1(const a: number): boolean;function func1(const a: string | number): number | boolean { if (typeof a === "string") return someString; // Assume the expected return type is implicitly narrowed here to number. else if (typeof a === "number") return someBoolean; // Assume the expected return type is implicitly narrowed here to boolean. } function func2(const b: string | number) { // const x = func1(b); // How would the type of x be represented?

if (typeof b === "number") { x; // Could x be narrowed to boolean here? } }

In general I find the idea that a type could describe a detailed relationship between some set of inputs and outputs very powerful, and surprisingly natural. In its core, isn't that what programming is all about? If a type could, for example, capture more specific details about the mapping between say, different ranges, or sub-classes of inputs to the expected ranges/sub-classes of outputs, and those can be enforced by the compiler, it would mean mean that the compiler could effectively "prove" correctness of some aspects of the program.

Perhaps encoding these relationships is not actually the most difficult aspect, but "proving" them is. I've read a bit about languages like Agda and Idris that feature dependent types but haven't really got deeply into that. It would be interesting to at least find some very limited examples of how (enforceable) dependent types would look like in Typescript. I understand that it may be significantly more challenging to implement them over impure languages like Javascript though.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/Microsoft/TypeScript/issues/12424#issuecomment-269350074, or mute the thread https://github.com/notifications/unsubscribe-auth/AERrBHAzma1qmDqp36OT6UeRTNpgHRmUks5rMT_xgaJpZM4K4ssm .

rotemdan commented 7 years ago

@isiahmeadows

Edit: Re-reading the responses, I think I might have been misunderstood: it was definitely not my intention to require the programmer to explicitly declare the complex return type - that would be tedious, but that the compiler could infer an "explicit" (in the sense of being well defined in the type system) type for the return value rather than just implicitly narrowing it as a localized "feature". I've also tried to come up with a more concise "abbreviated" form for the guarded type.

I've tried to re-read #12885 but I'm still not 100% sure if it describes the same issue as I mentioned here. It seems like it tries to address an aspect of overload inference that is somewhat related, but more like the "flip-side" of this issue:

// Unfortunately the parameter has to be 'const' or 'readonly' here for the 
// issue to be easily addressable. I don't believe these modifiers are currently 
// supported for function parameters but I'm using 'const' for illustration:
function func(const a: string): number;
function func(const a: number): boolean;
function func(const a: string | number): number | boolean {
    if (typeof a === "string") {
        return true; // <-- This should definitely be an error, but currently isn't.
    }
}

The weak return type checking in the body of overloaded function is a real world problem I've encountered many times and seems very worthy of attention. It might be possible to fix this through an implicit compiler inference "feature", but I felt that guarded polymorphic types could take it even a step further:

function func(const a: string): number;
function func(const a: number): boolean;
function func(const a: boolean): number[];
function func(const a: string | number | boolean) { // The return type is omitted by 
                                                    // the programmer. Instead, it it automatically
                                                    // generated by the compiler.
    if (typeof a === "string") {
        return true; // <-- Error here
    }
}

The generated signature could look something like:

// (The generated return type is concisely expressed using a 
// suggested abbreviated form for a guarded type)
function func(const a: string | number | boolean): 
    <typeof a extends string>: number, <typeof a extends number>: boolean, <typeof a extends boolean>: number[];

The abbreviated form (which is currently still in development), when written as a type alias, would look like:

type FuncReturnType = <T extends string>: number, <T extends number>: boolean, <T extends boolean>: number[];

As I've mentioned the type can be propagated back to the callers in an unresolved form if the argument type itself is a union, and it can even be partially resolved if that union is a strict sub-type of the parameter type:

// The argument type 'b' is a union, but narrower:
function callingFunc(const b: "hello" | boolean) {
    return func(b);
}

The signature of the calling function is generated based on a reduction of the existing guarded type to the more constrained union and substitution of the identifier used in the signature (a) with the target caller's parameter (b).

function callingFunc(const b: "hello" | boolean): 
    <typeof b extends "hello">: number, <typeof b extends boolean>: number[];

Perhaps this may seem, at first, like an "overly-engineered" solution, that takes it quite far but doesn't actually produce adequate amount of value in practice. It may be the case (though I'm not at all totally sure) if only simple types like string and number are involved, but what if the overloads described more fine-grained aspects of the parameters? like refinement types:

function operation1(const x: number<0..Infinity>): number<0..1>;
function operation1(const x: number<-Infinity..0>): number<-1..0>;
function operation1(const x: number) {
    // ...
}

Now what if multiple functions like these are composed?

function operation2(const x: number<0..10>): number<-10..0>;
function operation2(const x: number<-10..0>): number<0..10>;
function operation2(const x: number<-10..10>) {
    // ...
}

function operation3(const x: number<-10..10>): {
    return operation1(operation2(x));
}

To generate a signature for operation3 the compiler could "condense" this complex propagation of constrained unknowns into a simpler resulting signature:

function operation3(const x: number<-10..10>):
    <typeof x extends number<-10..0>>: number<0..1>, <typeof x extends number<0..10>>: number<-1..0>

I guess it wouldn't look as beautiful in Typescript as it would look with a more concise syntax like Haskell's, and the lack of pattern-matching, assurance of immutability of variables and purity of functions may reduce the usability of the feature, but I feel there's still a lot of potential here to be explored, especially since Typescript already performs disambiguation of unions using run-time guards, and has a variant of function overloading that is very atypical when compared with common statically typed languages.

Edits: I've corrected some errors in the text, so re-read if you only read the e-mail's version

dead-claudia commented 7 years ago

@rotemdan

To clarify #12885, it focuses on expanding the type inference for callers only, and it is very highly specific to overloads. I intentionally laid that focus, because I wanted to limit its scope. (It's much easier and more likely that a proposal will get somewhere when you keep it down to a single unit.)

So it is somewhat like a flip side, but the inverse of my proposal, using those same links to deduce the correct return type from the differing parameter type, would in fact be what you're looking for here.

It's an abstract enough concept it's hard to put it into precise terminology without delving into incomprehensible, highly mathematical jargon you'd be lucky to even hear Haskellers using.

zuzusik commented 7 years ago

It would be nice for this conditions to also allow any function matching.

Practical example with attempt to properly type sinon.createStubInstance:

a = function() {}
a.prototype.b = 3;
a.prototype.c = function() {};
stub = sinon.createStubInstance(a);
console.log(typeof stub.c.getCall); // 'function', c is of type SinonStub
console.log(typeof stub.b); // 'number' - b is still number, not SinonStub

To type it correctly we need the ability to match any function Seems like Function type should do this trick, right? Just want to make sure it will work correctly with this feature

Original discussion in DefinitelyTyped repo: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/13522#discussion_r94077747

dead-claudia commented 7 years ago

One other area where conditionals would help: The native Promise type should never accept a thenable in its generic parameter, since JavaScript does maintain the invariant that the argument to then callbacks are always coerced down to a single lifted value.

So, in order to properly type that, you have to constrain it to not include thenables.

rotemdan commented 7 years ago

I noticed that:

function func(const a: string | number | boolean): 
    <typeof a extends string>: number, <typeof a extends number>: boolean, <typeof a extends boolean>: number[];

Can be simplified and shortened even further using the already existing value type assertion expression syntax val is T:

function func(const a: string | number | boolean): 
         <a is string>: number, <a is number>: boolean, <a is boolean>: number[];

The general idea is that a is string represents a boolean-like assertion "type" just like T extends string (only it is bound to a specific variable), so it seems reasonable to allow both at that position.

I hope that having a more accessible and readable syntax would improve the chance of this being seriously considered for adoption.

Another thing to note is that the guarded type <a is string>: number, <a is number>: boolean, <a is boolean>: number[] can be seen as a subtype of the more general type number | boolean | number[]* so whenever it isn't possible to resolve it (say when the bound variable went out of scope and wasn't substituted by anything), it can always be cast back to its corresponding union supertype.

(* I mean, at least in the example I gave - this may not be true in general, but it seems like when used with overloaded function parameters that should mostly be the case, though more investigation is needed here)

dead-claudia commented 7 years ago

@rotemdan I like the general idea of that better, for explicitly typing my idea in #12885. I have my reservations about the syntax, though. Maybe something like this, a little more constraint-oriented with better emphasis on the union? It would also allow more complex relations syntactically down the road.

// `a`'s type is actually defined on the right, not the left
function func(a: *): (
    a is string = number |
    a is number = boolean |
    a is boolean = number[]
);

// Equivalent overload
function func(a: string): number
function func(a: number): boolean
function func(a: boolean): number[]

// Nearest supertype of the return type within the current system:
number | boolean | number[]

You could expand on this further down the road, inferring variable types to effectively reify overloads in the type system. In fact, this could be made also a lambda return type, unifying lambdas and function overloads.

// 2-ary overload with different return types
function func(a: *, b: *): (
    a is string & b is string = number |
    a is number & b is number = boolean |
    a is boolean & b is string = number[]
)

// Actual type of `func`
type Func = (a: *, b: *) => (
    a is string & b is string = number |
    a is number & b is number = boolean |
    a is boolean & b is string = number[]
)

// Equivalent overload
function func(a: string, b: string): number
function func(a: number, b: number): boolean
function func(a: boolean, b: string): number[]

I could also see this expanded to the type level and unified there as well, although I'd prefer to write that out in a more detailed proposal.

rotemdan commented 7 years ago

@isiahmeadows

This was just an initial attempt at coming up with a secondary shorter syntax semantically equivalent to the "overload-like" syntax:

type MyType<T extends string> = number;
type MyType<T extends number> = boolean;
type MyType<T extends boolean> = number[];

But where instead of using a generic parameter, the guard is bound to the type of a particular variable. In the longer version it would look like this

const a: string | number | boolean = ...;

type MyType<a is string> = number;
type MyType<a is number> = boolean;
type MyType<a is boolean> = number[];

I wanted the shorter syntax to be easily written (or inferred) in return types or normal positions. I used a comma (,) as a connector, although I also considered a vertical bar (|). The reason I chose the comma was that I wanted to make sure it is seen as order-sensitive. The union syntax is not order-sensitive in Typescript so I wanted to avoid that confusion:

This is how it looks with commas:

const a: string | number | boolean = ...
let b: <a is string>: number, <a is number>: boolean, <a is boolean>: number[];

And with the vertical bar (union-like) syntax it would look like:

let b: <a is string>: number | <a is number>: boolean | <a is boolean>: number[];

And with multiple parameters:

let b: <a is string, b is boolean>: number | 
       <a is string, b is number>: boolean | 
       <a is boolean>: number[];

I don't think this looks bad at all. If you think that the fact it is order-sensitive isn't going create confusion with regular unions than it seams reasonable to me as well. I used the angled brackets because I wanted to preserve the analogy from the "overload-like" syntax and maintain the intuitive sense that these are arguments for a type rather than a function of some sort. I used the colons (:) instead of equals (=) to make sure it isn't read such that there's an assignment from a type into the assertion type val is T. It looks a bit out-of-place to me to use an assignment-like operator in a type expression.

So in a function return type, the union-like syntax would look like:

function func(const a: string | number | boolean): 
         <a is string>: number | <a is number>: boolean | <a is boolean>: number[];

I'm not particularly "attached" to this syntax though. I think what you proposed was reasonable as well.

(this is a bit off-topic but I felt I had to say it:) I wish though, that the talented people at the Typescript team would actively involve themselves and contribute to discussions, rather than just acting mostly as passive bystanders. I think they maybe don't realize that if they did share their own ideas with the community, the community might be able to improve on them and some sort of "symbiosis" could form. Right now they are operating like they have their own closed "guild", and in some way the community is required to match their standards without really having them giving any incremental feedback. Until they make up their mind in their closed meetings, and then it is too late to change. There's something a bit patronizing about this. I just hope we're not wasting our time here.

dead-claudia commented 7 years ago

I'm currently working out a shotgun that'll also kill a few others, including subtraction types.

rotemdan commented 7 years ago

@isiahmeadows

I didn't think the ampersand (&) was a good choice for a connector two expressions that are closer to booleans. Maybe the double ampersand (&&) would have been better there, I guess. I wasn't exactly sure what the * meant as well (existential type?). I thought it looked interesting though, but maybe too "abstract" or "enigmatic" to the average programmer. I understand you tried to remove redundancies and unify the parameter types and the return type somehow, but there are several reasons why that wouldn't always be needed or the best thing to do.

Maybe I'll try to illustrate better where I was going to with the function syntax. Here's another variant of my notation, closer to yours, as I used = instead : (I'm starting to think it doesn't look as bad as I initially thought). Having the angled brackets would maybe allow to parse it more easily. It also makes it look like "anonymous" type aliases. I'm using the regular function overloading syntax, and I wanted to now show how it would look once these guarded types are inferred by the compiler.

So this is what the programmer annotates (this is how the actual code looks like):

function func(a: string, b: string): number;
function func(a: string, b: number): boolean;
function func(a: boolean, b: number): number[];
function func(a, b) {
}

And this is how it is inferred by the compiler within the body of the function:

function func(a: string, b: string): number;
function func(a: string, b: number): boolean;
function func(a: boolean, b: number): number[];
function func(a: string | boolean, b: (<a is string> = string | number) | number):
    <a is string, b is string> = number |
    <a is string, b is number> = boolean |
    <a is boolean> = number[] // Note `b` is not needed here to disambiguate the return type

You might have noticed I used this strange annotation:

(<a is string> = string | number) | number

It is an experimental combination of a "guarded" union member and a non-guarded union member. It could have also been written as something like:

(<a is string> = string | number) | (<*> = number)

Where <*> denotes "the rest" or "any other case".

Another advantage of the type alias like notation is that it makes it possible to combine both assertions on types (T extends U) and assertions on values (val is T):

const a: number | string | boolean;
type GuardedType<T extends string[], a is number> = ...
type GuardedType<T extends number, a is boolean> = ...

(I guess at this point it's too early to determine how useful this would be in practice, this is all very preliminary)

dead-claudia commented 7 years ago

@rotemdan I've come up with a concrete, slightly smaller-scoped and differently-scoped proposal in #13257. Basically, I'm granting the ability to statically assert many more things by introducing constraint types, to kill this and several others simultaneously.

KiaraGrouwstra commented 7 years ago

This issue has two sides:

6606 elegantly fixes both without new syntax:

interface isT<T> {
  (v: T): 1;
  (v: any): 0;
}
// type Matches<V, T> = typeof isT<T>(V);
// ^ fails until #6606
KiaraGrouwstra commented 7 years ago

@remojansen: I'm actually a bit surprised restricting the T type and overloading get doesn't cut it:

interface Immutable<T extends { [k: string]: any } | any[]> {
  get: {
    <K extends keyof T>(key: K): Immutable<T[K]>;
    <K extends keyof T>(key: K): T[K];
  }
  set: <K extends keyof T>(key: K, val: T[K]) => Immutable<T>;
  toJS(): T;
}

Just that function application (#6606) would tackle instanceof and more though (including using overloads as pattern-match style conditionals on steroids).

That said, I'd intuitively assumed instanceof and some others (spreads ..., operators for primitive literals, assertion operator !) to be available on the type level as well. But I'm now of the opinion that just two additions (function application + spreads) could combine to enable doing just about anything (ref).

AlexGalays commented 7 years ago

This is useful. As it stands, the recursive mapped type will dumbly apply the mapping to each level and property, be it an object, an Array or a primitive.

Funnily, I need exactly the DeepReadOnly from the first example but Arrays would require special treatment for an entire JSON tree to be mapped to read only: As it stands, it just makes the Array keys readonly (map, reduce, etc) and it even loses the index type.

KiaraGrouwstra commented 7 years ago

@AlexGalays: right, we hadn't touched on the index yet here. So getting the index type for a string-indexed object O is just O[string]. Adding a string index back to DeepReadonlyObject is just a matter of & { [k: string]: ... }. The catch: if your object lacks an index, that O[string] will error. In other words, what we need here is conditional logic based on some ObjectHasStringIndex check. The good news: ObjectHasStringIndex too can be tackled given #6606:

interface ObjectHasStringIndex {
  (o: { [k: string]: any }) => '1';
  (o: {}) => '0';
};
type ObjectHasStringIndexTestT = ObjectHasStringIndex({ [k: string]: 123 }); // '1'
type ObjectHasStringIndexTestF = ObjectHasStringIndex({ a: 123 }); // '0'
// ^ getting the return type of a function: proposal #6606
Igorbek commented 7 years ago

Just to recap once again how the original example would look like if we had extended typeof (#6606):

interface ReadonlyMapFn {
  <T extends object>(complex: T): DeepReadonlyObject<T>;
  <T>(other: T): T;
}
type DeepReadonly<T> = typeof ((null as any as ReadonlyMapFn)(null as any as T));
type DeepReadonlyObject<T> = {
    readonly [P in keyof T]: DeepReadonly<T[P]>;
};
SimonMeskens commented 7 years ago

I've got a simpler proposal without new syntax at #17325

jcalz commented 7 years ago

I don't know that extended typeof as in #6606 would solve this problem. Forgive me if this has been discussed elsewhere, but the compiler would also need to delay choosing an overload signature until after any generic type parameters have been specified. Otherwise it will always skip any overload with constraints unmet by the unspecified generic parameter. And since we need such constraints to represent the conditions on which to map types, we would still be stuck with unconditional mapped types. For example, even without extended typeof, we have:

interface IsPrimitive {
  <T extends object>(o: T): '0';
  <T>(o: T): '1';
}
declare const _isPrimitive: IsPrimitive;

var stringIsPrimitive: '1' = _isPrimitive(null as any as string); // ok
var regexpIsPrimitive: '0' = _isPrimitive(null as any as RegExp); // ok

function genericIsPrimitive<T>() {
  return _isPrimitive(null as any as T);
}

var stringIsPrimitive: '1' = genericIsPrimitive<string>(); // ok
var regexpIsPrimitive: '0' = genericIsPrimitive<RegExp>(); // FAILS!

With this in mind, I'm afraid @Igorbek's DeepReadOnly<T> implementation would just be a complicated type-level identity function and always return T. Am I missing something?

Thanks.

KiaraGrouwstra commented 7 years ago

@jcalz:

null as any as RegExp

btw, it seems null! as RegExp would do.

Am I missing something?

Oh, no, I believe you're ahead of me. Thank you for finding and reproducing this. That does look like a barrier. I haven't met many evaluation order issues yet, recently mostly #17456, though that appears different in cause.

I wonder if this could be salvaged or whether this is a design constraint. It'd be great if someone more knowledgeable than me could chime in there.

niieani commented 7 years ago

@tycho01 one evaluation-order issue I've bumped into was this: https://github.com/Microsoft/TypeScript/issues/14691, but that's basically a constraint of the type system that TS is based on, since it's not a flow / Hindley–Milner-based system, as @mhegazy perfectly explained in the issue.

I'm not knowledgable enough to know how "eager evaluation" might affect the proposal, but it's definitely worth thinking about.

SamPruden commented 7 years ago

17636 is a proposal that would handle this nicely. Basically it proposes type declaration overloads, which allow for really neat conditional mapping of types.

I've also just been thinking about filtered conditional mapping, and how that could work. By that I mean mapping only a subset of the members. I'm not sure if that precisely falls under the this issue, or if it should be its own thing.

~I've created a gist with my thoughts on that for now. I'll promote it to an issue if there's demand for a discussion, but I feel I've been spamming issues a little over the last few days and I'm pretending to be cool. I just thought that gist may be of interest to some of the people following this thread.~

I have no shame about issue spamming, that gist is now an issue at #17678.

KiaraGrouwstra commented 7 years ago

@jcalz: it seems the issue you raised had been the topic of discussion of #17471, so I've now taken further discussion on it that way. Now that my initial PR for 6606 seems ready-ish, eager overload resolution has indeed become a major bottleneck, as you predicted. I've been trying to look into it, though I haven't quite found a solution yet.

jcalz commented 7 years ago

Okay, so how crazy is something like this for DeepReadonly with proper treatment for arrays:

export type True = '1'
export type False = '0'
export type Bool = False | True
export type If<Cond extends Bool, Then, Else> = [Else, Then][Cond]

// please forgive me for what I am about to do
declare global {
  interface Object {
    '**isArray**': False
  }
  interface Array<T> {
    '**isArray**': True
  }
}

export type IsArray<T extends Object> = T['**isArray**'];
export type ElementType<T extends { [k: number]: any }> = T[number];

export type DeepReadonlyObject<T extends {}> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>
}
export type DeepReadonly<T extends {}> = If<
  IsArray<T>,
  ReadonlyArray<DeepReadonlyObject<ElementType<T>>>,
  DeepReadonlyObject<T>
>;

It seems to work on the playground (v 2.5.1-insiders.20170825):

interface Address {
  boulevard: string;  
  village: string;  
  prefecture: string;  
  postalCode: string;  
  realm: string;
}
interface Person {
  name: string;  
  age: number;  
  address: Address;  
  children: Person[];
}

const address: Address = {
  boulevard: '23 Skidoo Street',
  village: 'West Sussex',
  prefecture: 'East Dakota',
  postalCode: '12345-6789',
  realm: 'Federated North Vespucia',  
}
const alice: Person = {
  name: 'Alice Zyxxy',
  age: 10,
  address: address,
  children: []
}
const betty: DeepReadonly<Person> = {
  name: 'Betty Zyxxy',
  age: 40,
  address: address,
  children: [alice]
}

// sorry, alice, no birthday for you this way
betty.children[0].age = 11; // error, age is readonly

Thoughts? Is it an idea worth exploring or one to be buried and never spoken of again?

KiaraGrouwstra commented 7 years ago

@jcalz: it'd probably be considered bad practice if libraries were to mess with the built-in types, but in projects that won't be put up as an npm package, I'd say go for it. Given some progress, we'll end up getting past the workaround, at which point you can fix that bit and it's ready even for libraries to use (without risking complaints). Ditto for tuple support.

AlexGalays commented 7 years ago

@jcalz Seems like a good hack and the way built-ins are augmented doesn't seem too intrusive to me (all literals, etc will share that new property and there is no risk of incompatibility between libraries) Considering using it for an npm library.

Still, of course it would be much better to have a native way to do that.

niieani commented 7 years ago

@AlexGalays The problem is - what happens if you have two npm libraries that do the same -- or worse -- similar -- augmentation? You'd get conflicts and both will stop working. A workaround might be to prefix such global augmentation with the name of the package... still, that sounds very early web/jQuery-esque/globals & friends messy...

StreetStrider commented 7 years ago

@niieani I want to mention something a little off-topic, but augmentation turns out to be a really great idea when we dive into Symbols world. Starting with well-known symbols and ending in constructing custom protocols which allow libraries to actually play together (implementing things like fantasy/static land). I've been thinking what TypeScript could offer to allow such styles to exist and find out that TS should support «literal» Symbol types and presenting symbols in interfaces. I believe this would be enough.

dead-claudia commented 7 years ago

Edit: Fix my syntax to be at least close to accurate...

Here's an idea: maybe subtraction types (#4183) or type negation (#15480 comment) could alleviate this?

// Uses type negation
type DeepReadonlyObject<T> = {
    readonly [P in keyof (T & Record<P, !object>)]: T[P];
    readonly [P in keyof (T & Record<P, object>)]: DeepReadonlyObject<T[P]>;
}
Retsam commented 7 years ago

To tack onto @jcalz workaround, it seems like a similar approach can make the original DeepReadonly problem work: (...mostly, see below)

export type True = '1'
export type False = '0'
export type Bool = False | True
export type If<Cond extends Bool, Then, Else> = [Else, Then][Cond]

declare global {
    interface Object {
        '**isPrimitive**': False
    }
    interface Number {
        '**isPrimitive**': True
    }
    interface String {
        '**isPrimitive**': True
    }
    interface Boolean {
        '**isPrimitive**': True
    }
}

export type IsPrimitive<T extends {"**isPrimitive**": Bool}> = T["**isPrimitive**"];

export type DeepReadonly<T extends {}> = If<IsPrimitive<T>,
    T,
    { readonly [P in keyof T]: DeepReadonly<T[P]> }
>

(It's mostly working as written: it seems to preserve the object structure, and enforces the readonly constraint, but I'm losing type information at the "leaves". {foo: {bar: number}} seems to get transformed to {foo: {bar: any}} for some reason. Maybe I've got a mistake in there that someone else more experienced can catch.)


But it'd be really great to have actual support for this. There's been a lot of suggestion for solving this with other future Typescript features (advanced typeof, subtraction types, etc), but this really seems like the sort of thing that should be its own proper feature, IMO.

jcalz commented 7 years ago

I thought primitives are already treated differently by mapped types:

type Mapped<T> = {
  [K in keyof T]: 123
}

// primitives are not mapped
type MappedString = Mapped<string> // string
type MappedNumber = Mapped<number> // number
type MappedBoolean = Mapped<boolean> // boolean
type MappedLiteral = Mapped<'hello'> // 'hello'
type MappedObject = Mapped<object> // object
type MappedNull = Mapped<null>   // null
type MappedUndefined = Mapped<undefined> // undefined

// non-primitives are mapped, including (for some reason) arrays and functions 
type MappedRegExp = Mapped<RegExp> // {flags: 123, sticky: 123, etc etc}
type MappedArray = Mapped<Array<string>> // {find: 123, findIndex: 123, etc etc}
type MappedFunction = Mapped<Function> // {apply: 123, call: 123, etc etc}
type MappedFunctionSignature = Mapped<(x: string)=>number> // { }

// unions are broken into pieces, mapped separately (unless primitive), and rejoined as a union
type MappedUnion = Mapped<string | RegExp> // string | Mapped<RegExp>

// intersections are mapped as one piece, and never treated as a primitve
type MappedIntersection = Mapped<string & number> // {codePointAt: 123, toFixed: 123, etc etc}

For DeepReadonly, you happen to already get the recursive base case you want for string, number, boolean, undefined, null, literals, and unions of those. But note how arrays are not considered a primitive, even though I doubt it's particularly useful for anyone to map over methods like find().


I am still giving a big 👍/ ❤️ to a non-hack way of doing mapped conditional types, since I've run into situations both in my own code and in answering Stack Overflow questions where this would be the simplest solution.

dead-claudia commented 7 years ago

@jcalz Have you considered my specific idea, using type negation + union types?

niieani commented 7 years ago

@StreetStrider I agree, and see great value in type augmentation. My argument was against global augmentation hacks, not global augmentation per-se.

jcalz commented 7 years ago

@isiahmeadows Sure, if such a thing makes it into the language; does anyone know if type negation/subtraction is in the cards?


The problem I have with global augmentation is that I don't know how to package a module that augments global interfaces in such a way that you only see that augmentation inside code that imports from the module. (For example #15291). It's easy enough to give a collision-resistant name to the augmented imaginary property, but I don't want someone to see it at all unless their code depends on it, especially because no such property exists at runtime.


I wish there were a better canonical use case for this feature in the OP; DeepReadonly<> and DeepPartial<> are actually fairly close to usable due to the way mapped types short-circuit on primitives. The biggest problem with them is with arrays, which could be somewhat dealt with if someone (@ahejlsberg?) decided that mapped types should short-circuit on arrays as well (is there anyone who prefers mapped types to mutate the types of array methods rather than produce an array of the mapped element type?).

Here's one issue I've been trying to solve (not that I think this should be the canonical use case); I can't currently implement ResolveNamedTypes<> below:

class NamedType<K extends string> { ... }

// non-recursive description of recursive types
type DomainModel = {
  Person: {name: string, age: number, address: NamedType<'Address'>};
  Address: {street: string, city: string, residents: Array<NamedType<'Person'>>};
}

// actually recursive types
type Domain = ResolveNamedTypes<DomainModel>;
type Person = Domain['Person']; //{name: string, age: number, address: Address}
type Address = Domain['Address']; //{street: string, city: string, residents: Array<Person>}

I have a method that takes a data model (like DomainModel above) and deserializes some data into one of the types described by data model. But since the model is a non-recursive description of a possibly-recursive type (sort of like tying a knot), I need something like ResolveNamedTypes<> to properly type the method. With conditional mapped types this is actually reasonably straightforward; you need to switch on whether you're looking at a NamedType<> or not. Without conditional mapped types I think this is probably impossible.

Retsam commented 7 years ago

@jcalz Huh, I didn't realize that worked. Maybe the issue I've been running into isn't strictly an issue with the recursive mapping, then. Though what's weird (and what might be causing the issue I'm seeing), is that it seems a bit "fragile". This works:

type DeepReadonly<T> = {
    readonly [K in keyof T]: DeepReadonly<T[K]>
}

type DeepReadonlyObject = DeepReadonly<{ x: { y: "z" } }>

But this doesn't:

type Thru<T> = T;

type DeepReadonly<T> = Thru<{
    readonly [K in keyof T]: DeepReadonly<T[K]>
}>

type DeepReadonlyObject = DeepReadonly<{ x: { y: "z" } }>
//Expected: {readonly x: { readonly y: "z" }},
//Actual: {readonly x: any} ... though intellisense completes it as if it were {readonly x: {readonly y: {}}

I'd expect those to be identical; but perhaps there's some nuance as to why those should be different that I'm not understanding.

jcalz commented 7 years ago

@Retsam I think mapped types are "fragile" in a way I don't understand either. Issue #15756 was a blocker for a while, but it got fixed in #18042 (slated for TS 2.6). Maybe this is a related issue and someone (/me glances briefly at @ahejlsberg) would be interested in it?

jcalz commented 7 years ago

By the way, here is a sort-of implementation for ResolveNamedTypes<>:

export type True = 1
export type False = 0
export type Bool = 0 | 1
export type If<Cond extends Bool, Then, Else> = [Else, Then][Cond]
declare global {
  interface Object {
    '**isArray**': False
    '**isNamedType**': False
    '**typeName**': never
  }
  interface Array<T> {
    '**isArray**': True
  }
}

export type IsArray<T extends { '**isArray**': {} }> = T['**isArray**'];
export type ElementType<T extends { [k: number]: any }> = T[number];

export class NamedType<K extends string> {
  '**isNamedType**': True
  '**typeName**': K  
}

export type IsNamedType<T extends { '**isNamedType**': {} }> = T['**isNamedType**'];
export type TypeName<T extends { '**typeName**': {} }> = T['**typeName**'];

export type ResolveNamedTypes<T extends {}, M extends {} = T> = If<IsNamedType<T>,
  ResolveNamedTypesObject<M[TypeName<T>], M>, ResolveNamedTypesArrayOrObject<T, M>>

export type ResolveNamedTypesArrayOrObject<T extends {}, M extends {}> = If<IsArray<T>,
  ResolveNamedTypesArray<T, M>, ResolveNamedTypesObject<T, M>>

export interface ResolveNamedTypesArray<T extends {}, M extends {}>
  extends Array<ResolveNamedTypesObject<{ element: ElementType<T> }, M>['element']> { }

export type ResolveNamedTypesObject<T extends {}, M extends {}> = {
  [K in keyof T]: ResolveNamedTypes<T[K], M>
}

This works for cases like this the example I gave before

// non-recursive description of recursive types
type DomainModel = {
  Person: {name: string, age: number, address: NamedType<'Address'>};
  Address: {street: string, city: string, residents: Array<NamedType<'Person'>>};
}

// actually recursive types
type Domain = ResolveNamedTypes<DomainModel>;
type Person = Domain['Person']; //{name: string, age: number, address: Address} 🙂
type Address = Domain['Address']; //{street: string, city: string, residents: Array<Person>} 🙂

But I've hit a blocker when it comes to certain unions:

// string | number 🙂
type OkayUnion = ResolveNamedTypes<string | number, DomainModel>;  

// Person | Address 🙂
type OkayUnion2 = ResolveNamedTypes<NamedType<'Address'> | NamedType<'Person'>, DomainModel>; 

// want string | Person, but get string | Person | NamedType<'Person'> 🙁!  
type BadUnion = ResolveNamedTypes<string | NamedType<'Person'>, DomainModel>; 

The main issue here is being able to take a union type and partition it according to some type predicate. My If<> type function doesn't act the way I want on unions. If Cond<T> is True and Cond<F> is False, then If<Cond<T|F>, Then<T|F>, Else<T|F>> evaluates to Then<T|F>|Else<T|F>, whereas what I really want is Then<T>|Else<F>. In this case, Cond<X> is a check for whether X is an instance of NamedType.

The upshot is that I don't know if the global-augmentation method is the best way to accomplish mapped conditional types (even if it could be done with unobtrusive Symbols). With type negation and subtraction (à la @isiahmeadows's comment) one could do something like Then<U & NamedType> | Else<U & !NamedType> for a possible-union type U and I think it would behave appropriately for unions. Intersections, not so much.

Bumping up a level, to do conditional mapped types "right" in a way that behaves as desired in the face of primitives, arrays, unions, and intersections, we need to be able to write more powerful type functions than we currently can.

Whoops, that got long. 🤐

KiaraGrouwstra commented 7 years ago

@jcalz: Right. So mapped types are one way to map over unions, but only take string literals for input.

I don't believe & works here, as it'll just complain NamedType needs a parameter. This then yields you impossible types, just like string & number yields something useless instead of say reducing to never. You probably don't want that, as you can no longer really operate on types like that anymore. To use type subtraction, another obstacle is it'd likely require you to know the exact type you'd wanna subtract, while we also have no way grab parts from a union.

The direction I'd been thinking in to solve this would be an operator to convert a union to a tuple, so we could just iterate over it. That's just one idea though -- we may well wanna brainstorm some more to find something nice that covers anything needed here.

jcalz commented 7 years ago

@tycho01: I guess U & NamedType<{}> and U & !NamedType<{}> would work because in TS type parameters are covariant (a whole other can of worms), but you're right, unless the type checker aggressively reduced impossible types to never, this wouldn't work either. And there's another rabbit hole (#18210 and all the issues like it).

Explicitly iterating over unions (and intersections) would definitely be powerful enough to get this done.

Oh, one more thing to keep an eye on: the dreaded "type definition circularly references itself" error. In DeepReadonly<>, and ResolveNamedTypes<> and other such things, I've been smashing into it and tiptoeing around it. I would love a general solution to it, instead of what seems like a bunch of hacks.

KiaraGrouwstra commented 7 years ago

@jcalz: it's interesting you mention intersections. The thought had occurred to me, but I hadn't so much found strong use-cases for it like for unions.

dead-claudia commented 7 years ago

@jcalz

but you're right, unless the type checker aggressively reduced impossible types to never

IMHO it's odd that it doesn't. Almost seems like a design bug to me.

Edit: This should include things like number & string, {} & void, and [string] & [number]. Note that this would be a wildly breaking change, and it would increase the necessity of nominal subtyping (e.g. for programming with units).

KiaraGrouwstra commented 7 years ago

@isiahmeadows: I agree; I upvoted @jcalz's #16386 now.

KiaraGrouwstra commented 7 years ago

@jcalz: I think I got past your evaluation issue (https://github.com/Microsoft/TypeScript/issues/12424#issuecomment-320511965); the following now works for me at #17961:

interface isT<T> {
  (v: T): '1';
  (v: any): '0';
}
type Matches<V, T> = isT<T>(V);
type isBool = isT<boolean>;
let falseBool: isBool(false); // 1
let strBool: isBool(string); // 0

Doesn't address mapping unions, but it may help make the global pollution unnecessary.

KiaraGrouwstra commented 7 years ago

I now made type calls union-proof (can always map over union inputs) to solve #17077. Still having trouble getting the full thing here to work though.

@StreetRider:

TS should support «literal» Symbol types and presenting symbols in interfaces. I believe this would be enough.

Actually you can, like { a: 1, [Symbol.unscopables]: 2 }.

KiaraGrouwstra commented 7 years ago

There's another attempt at a recursive readonly at #10725.