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

Constraint Types Proposal #13257

Closed
dead-claudia opened this issue Jan 2, 2017 · 40 comments
Closed

Constraint Types Proposal #13257

dead-claudia opened this issue Jan 2, 2017 · 40 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@dead-claudia
Copy link

dead-claudia commented Jan 2, 2017

Edit: Now a gist, with all the previous edits erased.

See this gist for the current proposal. Better there than a wall of text here.

@asfernandes
Copy link

Why not use a ternary operator?

Instead of:

let other: (value is number: string | value is string: boolean)

This:

let other: (value is number ? string : value is string ? boolean)

Looks less confusing for me.

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Jan 2, 2017

this is what they call dependent types, isnt it?

if so, is predicate alone looks too constraining (pun intended) it should be any predicate:

type T<A> = string when A is number && A % 2 === 0;
type T<A> = number when A is string && A.length > 1;

@dead-claudia
Copy link
Author

@Aleksey-Bykov It's not quite dependent types, because values cannot be lifted to types, nor can types be lifted to values. This proposal only allows proper type -> type functions (we already have value -> value functions), but it's missing the other two cases. And no, typeof does not provide that ability, because it merely grabs the type of that binding, and is equivalent to using a type alias with explicit types.

a is T already exists for things like this:

interface ArrayConstructor {
    isArray(value: any): value is any[];
}

And Is<Type, Super> is a static instanceof assertion, and you can use it like this:

function staticAssert<T>() {}
declare const value: any

if (Array.isArray(value)) {
    staticAssert<Is<typeof value, any[]>>()
} else {
    staticAssert<NotA<typeof value, any[]>>()
}

@dead-claudia
Copy link
Author

@asfernandes

The reason I chose not to use a ternary is because of two reasons:

  1. It visually conflicts with nullable types, and is also much harder to parse (you have to parse the whole expression before you know if the right side is a nullable type):

    type Foo = (value is number ? string : boolean)
    type Foo = (value is number? ? string : boolean)
  2. Types may have more than two different conditions (e.g. with overloaded types), and the ternary operator doesn't make for very readable code in this case.

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Jan 9, 2017 via email

@dead-claudia
Copy link
Author

@Aleksey-Bykov TypeScript only cares about newlines for ASI, and there's no other precedent for newline significance. Additionally, I intentionally phrased the constraint types as a union of constraints, so they could be similarly narrowed with control flow (just as current union types can be), and that's why I used the | union operator.

As for why I chose a union instead of separate type overloads, here's why:

  1. I don't need to create a dedicated impossibility type, and can just declare any particular token as a keyword. This limits the compatibility impact and simplifies parsing greatly.

  2. Type aliases will remain to always resolve to something, so it doesn't involve rewriting core logic that relies on this assumption. And the TypeScript code base has historically been a giant ball of mud with assumptions like these littered across the code base.

  3. I don't have to create a type alias to define such a type every time. This not only provides flexibility (I can define one any time I want), but it also lets me create lambda return types that depend on the types of their parameters, even if they're unions or generics whose type is detected at runtime. The latter is plainly not possible with named types.

  4. Return types can statically depend on the generic type parameter as well as the parameters themselves. This is a must for capturing and properly constraining Promise.resolve as a standalone method, since it's literally impossible to ever return a Promise<Thenable<T>> due to thenable coercion.

I did in fact already consider the idea of type overloads, and initially tried that route before posting this. The above issues, especially the first three, are why I elected to go a different route instead.

@Artazor
Copy link
Contributor

Artazor commented Jan 25, 2017

@isiahmeadows, your proposal is what I would like to see in TypeScript.
However, I do not think that this is the union. It is the ordered selection, not all alternatives together (like in PEG). And narrowing should be designed from the scratch for this kind of types.

I would like to use a / instead of | to emphasize that the order matters (the same intuition as PEG)

@Artazor
Copy link
Contributor

Artazor commented Jan 25, 2017

However, I wanted to move in the direction of type decomposition via switch-types

type CoerceToValue<T> = switch(T)  { 
     case<V> Thenable<V>: V;
     default: T;
}  

or even

type CoerceToValue<T> = switch(T)  { 
     case<V> Thenable<V>: CoerceToValue<V>;
     default: T;
}  

@dead-claudia
Copy link
Author

dead-claudia commented Jan 27, 2017

@Artazor That might work, too, but I have my reservations in terms of expressiveness. How would you do something equivalent to my Super<T> type (any supertype of T), and how would you constrain the T correctly with Promise<T> (where T cannot be a thenable)?

// Using my proposal's syntax
type Super<T> = (U in T extends U: U);
interface Promise<T extends (T extends Thenable<any>: * | T: T)> {}

@bobappleyard
Copy link

What about never as impossibility assertion?

@dead-claudia
Copy link
Author

@bobappleyard Can't use it because it's already a valid return type (specifically a bottom type, a subtype of every type).

function error(): never {
  throw new Error()
}

I need an impossibility type, where even merely matching it is contradictory.

@DanielRosenwasser DanielRosenwasser added the Suggestion An idea for TypeScript label Mar 11, 2017
@dead-claudia
Copy link
Author

Note to all: I updated my proposal some to aid in readability.

@donaldpipowitch
Copy link
Contributor

donaldpipowitch commented Mar 27, 2017

I'd like to solve something like #12424.

I just want to check, if I have understood this proposal correctly.

Say I have this example:

// some nested data

interface Data {
  foo: string;
  bar: {
    baz: string;
  };
}

const data: Data = {
  foo: 'abc',
  bar: {
    baz: 'edf'
  }
};

// a generic box to wrap data

type Box<T> = {
  value: T;
};

interface BoxedData {
  foo: Box<string>;
  bar: Box<{
    baz: Box<string>;
  }>;
}

const boxedData: BoxedData = {
  foo: {
    value: 'abc'
  },
  bar: {
    value: {
      baz: {
        value: 'edf'
      }
    }
  }
};

I'd like to express BoxedData in a generic way. Would this be correct?

// should box everything and if T is an object,
//   - it should box its properties
//   - as well as itself in a box
//   - and arbitrary deep
type Boxing<T> = [
    T is object: [P in keyof T]: Boxing<T[P]> & Box<T>,
    T: Box<T>,
]

type BoxedData = Boxing<Data>

And how would it look like with arrays? E.g.

interface Data {
  foo: string;
  bar: {
    baz: string;
  };
  foos: Array<{ foo: string }>;
}

// arrays should be boxed, too and all of the items inside of the array
type Boxing<T> = [
    T is object: [P in keyof T]: Boxing<T[P]> & Box<T>,
    T is Array<ItemT>: Boxing<Array<ItemT>> & Box<T>,
    T: Box<T>,
]

type BoxedData = Boxing<Data>

const boxedData: BoxedData = {
  foo: {
    value: 'abc'
  },
  bar: {
    value: {
      baz: {
        value: 'edf'
      }
    }
  },
  foos: {
    value: [
      {
        foo: { value: '...' }
      }
    ]
  }
};

My use case are forms which can take any data (nested and with arrays) as an input and have a data structure which matches the input data structure, but with additional fields (like isValid, isDirty and so on).

@dead-claudia
Copy link
Author

@donaldpipowitch Your Boxing<T> type is almost correct.

// arrays should be boxed, too and all of the items inside of the array
type Boxing<T> = [
    T extends object: {[P in keyof T]: Boxing<T[P]> & Box<T>},
    U in T extends U[]: Boxing<U[]> & Box<T>,
    T: Box<T>,
]

Note a few changes:

  1. variable is Type requires the LHS to be a value binding (e.g. let foo = ...), not a type binding (e.g. type Foo = ...). What you're really looking for is T extends Type.
  2. I fixed your mapped type to use the correct syntax.
  3. I used an existential on the array line to capture the array item type.

@dead-claudia
Copy link
Author

@donaldpipowitch Updated with my newest changes:

type Boxing<T> = [
    case T extends object: {[P in keyof T]: Boxing<T[P]> & Box<T>},
    case U in T extends U[]: Boxing<U[]> & Box<T>,
    default: Box<T>,
]

I added keywords to make it a little more readable and obvious at the cost of a little extra verbosity.

@bobappleyard
Copy link

bobappleyard commented Mar 27, 2017 via email

@dead-claudia
Copy link
Author

@bobappleyard Thanks for the catch! Should be fixed now.

@RyanCavanaugh RyanCavanaugh added the In Discussion Not yet reached consensus label Mar 27, 2017
@donaldpipowitch
Copy link
Contributor

@isiahmeadows Thank you. This looks really useful for our use case. I hope this will be implemented someday ❤

@MeirionHughes
Copy link

that little extra verbosity finally makes it readable to a layman, such as myself. :P

@dead-claudia
Copy link
Author

@MeirionHughes Welcome

@MichaelTontchev
Copy link

Looks like a great idea with a lot of immediate uses :) Any idea when this might hit production? :)

@mweststrate
Copy link

Same here, mobx-state-tree would benefit highly from this feature

@goodmind
Copy link

goodmind commented May 9, 2017

@isiahmeadows

I want to write types for Telegram API Update type. Are this right types?

type XMessage = { message: Message }
type XEditedMessage = { edited_message: Message }
type XChannelPost = { channel_post: Message }
type XEditedChannelPost = { edited_channel_post: Message }
type XInlineQuery = { inline_query: InlineQuery }
type XChosenInlineResult = { chosen_inline_result: ChosenInlineResult }
type XCallbackQuery = { callback_query: CallbackQuery }

type Update<X> = [
   case X is XMessage: X,
   case X is XEditedMessage: X,
   case X is XChannelPost: X,
   case X is XEditedChannelPost: X,
   case X is XInlineQuery: X,
   case X is XChosenInlineResult: X,
   case X is XCallbackQuery: X,
   default: throw
]

const value = getUpdate()
const u: Update<typeof value> = value

@dead-claudia
Copy link
Author

dead-claudia commented May 11, 2017

@goodmind Constraint types do not resolve this. (See edit) You would be interested in this proposal with disjoint types.

The expression value is Type takes a variable name on the left hand side, like in let value = 2, not a type name like in interface X {}. It's an expanded version of the existing return-only type, which similarly only accepts a parameter name, like in isArray(value: any): value is any[] within the standard library.

Edit: It is theoretically possible as demonstrated in this code snippet, but given union/intersection types, you might prefer a dedicated operator anyways.

type Xor<A if constraint A, B if constraint B> = [
    case A extends true: B extends false,
    default: B extends true,
];
type Disjoint<A, B> = [default U in: Xor<U extends A, U extends B>];

@goodmind
Copy link

@isiahmeadows what is if constraint A ?

@dead-claudia
Copy link
Author

@goodmind I updated the proposal with some new types and cleaned it up quite a bit when I moved it to a gist.

@donaldpipowitch
Copy link
Contributor

Thank you for the effort. Do you know what would be the next steps to get something like this implemented?

@goodmind
Copy link

@isiahmeadows it is sometimes cumbersome to read this gist (and Xor and Disjoint types as well). Can you add more code examples to gist?

@dead-claudia
Copy link
Author

@goodmind I'll update it with the constraint types explained better. I'll also introduce a high-level summary, to make it easier to understand.

@dead-claudia
Copy link
Author

dead-claudia commented May 13, 2017

@goodmind Updated the gist. Also, the Disjoint type was wrong (it returned the Xor result rather than a mapped case variable), so I corrected it. As for Xor, I just used the common "xor" abbreviation for the logical "exclusive or" (either, but not both).

@dead-claudia
Copy link
Author

Gist updated with compile-time assertions

@jcalz
Copy link
Contributor

jcalz commented Jun 13, 2017

Would this proposal allow someone to write an type/interface that represents just the readonly properties of a type?

Like

type WritableKeys<T> = // somehow?
type ReadonlyKeys<T> = // somehow?
type WritablePart<T> = { [K in WritableKeys<T>]: T[K] };
type ReadonlyPart<T> = { [K in ReadonlyKeys<T>]: T[K] };
type Identityish<T> = WritablePart<T> & Readonly<ReadonlyPart<T>>;

so that you can write a safe version of

function unsafeSet<T,K extends keyof T>(obj: T, prop: T, val: T[K]) {
    obj[prop] = val; // what if T[K] is readonly?  
}

like

function safeSet<T,K extends WritableKeys<T>>(obj: T, prop: T, val: T[K]) {
    obj[prop] = val;
}

Similarly for making an interface from a class, excluding any private members:

type PublicKeys<T> = // somehow?
type ProtectedKeys<T> = // somehow?
type PrivateKeys<T> = // somehow?
type PublicPart<T> = { [K in PublicKeys<T>]: T[K] };  

These are constraints on types but I'm not sure if there's any way to express them. If this is not the appropriate issue I'll find or create another one. Thanks!

@dead-claudia
Copy link
Author

@jcalz No, because type-level property reflection is out of scope of this proposal.

@jcalz
Copy link
Contributor

jcalz commented Jun 14, 2017

Thanks.

EDIT: wait, couldn't you do something like

type ReadonlyPart<T> = {
    [K in keyof T]: [case ({K: T[K]} is {readonly K: T[K]}): T[K]];
}

?

@dead-claudia
Copy link
Author

@jcalz That doesn't work (for readonly), for two reasons:

  1. The LHS of value is Type is restricted to identifiers (for now), to ease general implementation.
  2. Read/write properties aren't soundly checked ATM (bug), and the correct behavior would have read-write subtype readonly (i.e. RW extends RO), but not vice versa.

Assuming the latter and #15534 are both fixed, you would need to do this instead:

type ReadonlyKeys<T> = keyof ReadonlyPart<T>;
type ReadonlyPart<T> = {
    // Check if not assignable to read/write.
    [K in keyof T]: [case !(T extends {[K]: T[K]}): T[K]];
};

For any other modifier (public, private, protected), it still remains impossible, because TypeScript does not expose those at all at the type level outside their scope.


I thought there was likely a way with readonly properties, but couldn't come up with one initially.

@ajbouh
Copy link

ajbouh commented Jan 31, 2018

Are there any active plans to implement this proposal in TypeScript?

@mkusher
Copy link

mkusher commented Feb 1, 2018

@ajbouh check conditional types PR: #21316

@dead-claudia
Copy link
Author

@ajbouh Item of note: it's really only implementing part of it. Specifically, a variant of my proposal is being implemented right now:

The third point could be easily shimmed with some boilerplate in terms of the first, but the fourth currently cannot.

As for a quick TL;DR, for those who aren't fully aware of what each one is, or how it's implemented:

  • Conditional type: this is like a lazy if/else, but for types. The proposal being implemented is literally a type-level ternary operator, the same conceptually as the value-oriented version you already know.
  • Existential type: this is your generic "for every item", but instead of you having to use any, you can still be safe about it when you pass it to another whatever, and it just works. They use infer at each use site within the condition, where I specify it outside the type.
  • Constraints as types: this is exactly how it sounds - T extends Whatever being equivalent to the in-progress T extends Whatever ? true : false, and the ternary just being SomeConditionalBoolean ? Foo : Bar. It hasn't been outright rejected, but the TS team has noted it would be rather difficult.
  • Impossible type: this is exactly what you'd expect: a type that's an error if ever evaluated. I have not received any indication, nor found/read any, from the TS team that they are for or against this, but it's a pretty natural continuation of having conditional types - you might want to conditionally reject a particular type. (Promises come to mind here - a Promise<Promise<T>> type is literally impossible.)

* TS team: if there is an issue open for either of these two, please tell me so I can edit this in.

@leandro-manifesto
Copy link

I have been checking issues similar to this one lately.

The Impossible type seems to be tracked in #23689 and Promise<Promise<T>> is in #27711, which has a multitude of issues and pull requests associated with it.

I'm also waiting for support for upper-bound contraints (super, narrows, etc) that is not mentined here but is discussed in #9252.

@dead-claudia
Copy link
Author

Closing this issue as the TS team has basically implemented the majority of it through its conditional types, just with a different, more JS-like syntax.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests