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

Design Meeting Notes, 9/25/2020 #40779

Closed
DanielRosenwasser opened this issue Sep 25, 2020 · 20 comments
Closed

Design Meeting Notes, 9/25/2020 #40779

DanielRosenwasser opened this issue Sep 25, 2020 · 20 comments
Labels
Design Notes Notes from our design meetings

Comments

@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Sep 25, 2020

Object Spreads and Union Types

#40754
#40755

  • One set of users: have been working with long builds, throw 8GB at it

    • Recent change: more gigs, compilation "seems to never terminate".
  • They have a huge object type written like

    const cssProps = {
        ...{yadda && {
            backgroundColor: "...",
            color: "...",
            // ...
        }}
        // ... 100 more of these
    }
  • This ends up creating a MASSIVE union type based on making a conditional property.

  • The users don't want a union, they just want a big old blob of optional properties.

  • We could just "recognize the pattern" and provide a "weaker" type (i.e. one type with lots of optionals instead of a union where every other union member makes the property undefined).

  • In 3.8, we added this logic, but only for single-property objects. We were afraid to do this too broadly.

  • Other workarounds?

    • as CSSProperties
    • Some helper function.
  • Can we leverage a contextual type?

    • Well the heuristic can't just look and say "do I have a union?"
  • Some feel like we should just always do this.

    • Could also say that, when performing a spread, "do I have a conditional type where the property type is optional? If so, create an optional property."
  • One option might be to provide a quick fix to write the all-optional property object type when the expression is too complex.

  • Sounds like we always want to use the all-optional properties, but can't do that for 4.1.

    • Sounds like we also need a limiter for 4.1 though.

throw Types

#40468

  • If a throw type "ever comes to pass", it becomes an error.
  • In principle, really like the idea to convey reasons of why conditional types didn't actually work.
    • "Signaling nevers, or nevers with a reason."
  • Issue: "if it ever gets evaluated" leaks implementation details of instantiation.
    • Our type system's instantiation semantics aren't strictly evaluated, but it's not always lazily evaluated either!
      • Basically non-strict is the best you can say.
    • Lots of places where instantiation can also be surprising (e.g. looking at constraints).
  • Surprising how well errors can be tied back to specific locations.
    • That's currentNode, we've had that for a while, works decently well.
    • Though could end up with instantiation anywhere, so...
  • Potential performance problem?
    • When you use these instead of never in the tail, you end up with more types.
    • Could potentially intern these though, not necessarily the biggest concern.
  • Would be ideal if this built on never, especially because of unioning semantics.
    • Sometimes you don't want that either.
  • It sounds like there are really two things:
    • a signaling never type
    • a signaling "poison" type
      • the anti-any
  • Conclusion: like the idea, not necessarily this as the future direction. Two potential directions of "signaling never" and signaling "anti-any".

Conditional Assignability

  • [[Look how our type helpers look like parser combinators now.]]

  • But you can only trigger the inference on these type aliases for literals by writing them in call positions.

  • What if you could?

    // Concept
    type Digit = match S extends string =>
      S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;
  • Mental model:

    • When relating a type Foo to a Digit, S is bound to Foo, and is then related to that.
    • The relationship succeeds when the source matches the constraint (string) and the type then evaluates to true.
    • When used as a source type, the type is the constraint.
  • Out of time.

@DanielRosenwasser DanielRosenwasser added the Design Notes Notes from our design meetings label Sep 25, 2020
@DanielRosenwasser
Copy link
Member Author

Code sample from @ahejlsberg

type Digit = match S extends string =>
    S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;

type Digits = match S extends string =>
    S extends `${​​​​​Digit}​​​​​${​​​​​infer R}​​​​​` ? R extends '' | Digits ? true : false : false;

type Decimal = match S extends string =>
    S extends `${​​​​​'' | '+' | '-'}​​​​​${​​​​​Digits}​​​​​` ? true
    S extends `${​​​​​'' | '+' | '-'}​​​​​${​​​​​Digits}​​​​​.${​​​​​Digits}​​​​​` ? true :
    false;

type Extent = match S extends string =>
    S extends `${​​​​​Decimal}​​​​​${​​​​​'px' | 'pt'}​​​​​` ? true : false;

@DanielRosenwasser
Copy link
Member Author

One outer commentary to help the mental model on this one - for any type T, you can conceptually roll it up to

match S extends T => true

Again, this is just a concept. Not committed, syntax not tied down.

@awerlogus
Copy link

awerlogus commented Sep 26, 2020

Conditional assignability can be already used for function parameters case.

declare enum digit { digit = '' }

type Digits = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

type Digit<D> = D extends Digits ? D : digit

declare function doSomethingWithDigit<D>(digit: Digit<D>): any

// OK
const a = doSomethingWithDigit('1')

// Argument of type '"a"' is not assignable to parameter of type 'digit'.ts(2345)
const b = doSomethingWithDigit('a')

It will be great to see this feature on the type level. But we also need to be able to handle cases when our type depends on another ones. So I suggest make this conditional types generic.

type LensKey<O extends Object> = match S extends string =>
   // Traversal case
  O extends ReadonlyArray<any> ? S extends '[]' ? true
    : S extends keyof O ? true : false

declare function getLens<O extends Object, K extends LensKey<O>> (obj: O, key: K): any

@jack-williams
Copy link
Collaborator

jack-williams commented Sep 27, 2020

Would conditional assignability be a better place to surface 'throw types'?

The conditional assignability concept directly hooks into the definitions of type relations, so would this be a better place to add helpful information?

Every match type can have some doc string that is optionally used by the type-checker when the match expression evaluations to false. For example:

type NonZero = match N extends number => [N] extends [0] ? false : true
               reason `Non-zero number expected.`;

function checkedDivide(x: NonZero): number {
    if (x === 0) throw new Error('')
    return 5 / x
}

checkedDivide(0) // error. 0 is not assignable to NonZero: Non-zero number expected.


type Uncallable = match N extends never => false
                  reason `This function should never be called`

function dontCallMe(arg: Uncallable) { }

As an aside. What happens in this case?

type Weird = T extends never => false;
const x: Weird = undefined as any as Weird;

@Raiondesu
Copy link

Raiondesu commented Oct 1, 2020

Looks like conditional assignability (with somewhat extended functionality) can make #17428 irrelevant, while also providing a way to partially (or even completely) solve #14400, which is very good news:

// Return the type to check against instead of basic true/false validation
type Match<T> = match S => S extends T ? S : T;

declare const f: <U>(input: Match<U>) => typeof input /* should preserve original matched type */;

interface Foo {
    bar: 'baz';
}

const obj = {
    test: 42
};

// Validate obj by a generic type
const r = f<Foo>({
    bar: 'baz',
    ...obj,
});
/*
r is of type
{
  bar: 'baz',
  test: 42
}
*/

const err = f<Foo>({
	// Error here?
	baz: 'bar'
});

So, instead of the resulting type of conditional assignability to tell whether the type is assignable or not, it seems to me as a lot more practical and powerful to make the resulting type the one to check against.

This way, previous examples can be rewritten in the following fashion:

type Digit = match S extends string =>
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
	// Return the matched type itself, as it already meets our requirements
	? S
	// Return `never` to trigger impossibility of assigning `S` to `Digit`
	: never;

type Digits = match S extends string =>
    S extends `${​​​​​Digit}​​​​​${​​​​​infer R}​​​​​` ? R extends '' | Digits ? S : never : never;

type Decimal = match S extends string =>
    S extends `${​​​​​'' | '+' | '-'}​​​​​${​​​​​Digits}​​​​​` ? S
    S extends `${​​​​​'' | '+' | '-'}​​​​​${​​​​​Digits}​​​​​.${​​​​​Digits}​​​​​` ? S :
    never;

type Extent = match S extends string =>
    S extends `${​​​​​Decimal}​​​​​${​​​​​'px' | 'pt'}​​​​​` ? S : never;

This allows to not only forbid or allow assignability, but also restrict it more easily without multiple deeply branching conditionals, while also keeping the declaration much more readable:

// This contraption is not intuitive nor understandable
///unless you know beforehand that it somehow validates
// the type based on whether the conditional returned true or false,
// as no types in TS currently work this way
type Digit = match S extends string =>
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;

// This, however, aligns perfectly with how types currently work from the user's perspective,
// and will probably raise less questions about what this means exactly, allowing easy adoption
type Digit = match S extends string =>
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? S : never;

declare function f(d: Digit): unknown;

@awerlogus
Copy link

@Raiondesu this behaviour can be already done using plain TypeScript.

type Cast<X, Y> = X extends Y ? X : Y

declare const getValidator: <V>() => <U>(input: Cast<U, V>) => typeof input

interface Foo {
  bar: 'baz'
}

const validateFoo = getValidator<Foo>()

// OK
validateFoo({
  bar: 'baz',
  test: 1
})

// Error
validateFoo({
  baz: 'bar'
})

And you also can use enum declarations as named never type like here

declare enum digit { digit = '' }

type Digits = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

type Digit<D> = D extends Digits ? D : digit

declare function doSomethingWithDigit<D>(digit: Digit<D>): any

// OK
const a = doSomethingWithDigit('1')

// Argument of type '"a"' is not assignable to parameter of type 'digit'.ts(2345)
const b = doSomethingWithDigit('a')

But the main problem of such approach is parameters inference using infer keyword

// [digit: digit] or [digit: never] if you used never type
type P = Parameters<typeof doSomethingWithDigit>

Type P is absolutely useless here because you can not assign anything to this.

It can be solved by your suggested match syntax with types returned, but for non generic type cases only. Generic parameters will be still inferred as unknown. Thus, we come to the conclusion that the main problem here is not the syntax itself, but the lack of higher-order types.

@Raiondesu
Copy link

Raiondesu commented Oct 2, 2020

@awerlogus, the behaviour you described in the first example is exactly what bothers people in #14400 (see last few comments there). This is undesirable boilerplate code that mangles libraries' APIs, and people rightfully complain about it.

I agree, however, with defining the core problem as a lack of HOTs (as more and more workarounds for them seem to pop-up in TS over time).
To some extent. As modifications to conditional assignability, that I proposed earlier, fix the problem of inference, because there are suddenly no captured generic types for TS to miss on inference if "conditional assignables" are used in their place.

The core problem I was trying to solve there was TSs inability to simply validate (not restrict!) and forward type information without capturing it in some sort of a generic parameter, which is, I think, the whole reason behind #17428 ("if TS can only do that using generics, let's make everything generic!").
So, naturally, conditionally assignable types immediately seemed to me like a simpler solution to this problem. Albeit, with slight modifications to the originally discussed functionality.

I do understand I might be overthinking this, but after all, if conditionally assignable types "already" capture the matched type (somehow, outside of generics) to validate it - why refuse to use it to the end anyway?

@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 22, 2020

While I really like the concept of the conditional assignability, I think a major issue is how you match one against the other; i.e.:

type Digit = match S extends string =>
  S extends "0" | "1" | "2" | "3" | 4" | "5" | "6" | "7" | "8" | "9" ? true : false

type Bit = match S extends string =>
  S extends "0" | "1" ? true : false

type Test = Bit extends Digit ? true : false;

@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 22, 2020

Unrelatedly, this would give us many advanced types:

type Complement<T> = match U extends any => U extends T ? false : true;
type StrictSubtype<T> = match U extends T => T extends U ? false : true;
type NonUnion<T = any> = match U extends T => IsUnion<U> extends true ? false : true;

type UniqueSymbol = StrictSubtype<symbol> & NonUnion<symbol>;

// Utility, works sans this change
type IsUnion<T> = (T extends any ? (x: (x: T) => T) => any : never) extends (x: infer U) => any ? U extends (x: any) => infer V ? [T] extends [V] ? true : false : false : false;

@harrysolovay
Copy link

harrysolovay commented Oct 22, 2020

Looking at the digits example from Anders' snippet.

type Digit = match S extends string =>
   S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;
type Digits = match S extends string =>
   S extends `${​​​​​Digit}​​​​​${​​​​​infer R}​​​​​` ? R extends '' | Digits ? true : false : false;

And it leaves me thinking...

Without this syntax, users might solve this problem as follows.

  1. Define a type which iterates over a string and gives us a boolean, representing whether the string is solely of digits.
type AreDigits<S extends string> = S extends `${infer C0}${infer CRest}`
  ? C0 extends  '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
    ? CRest extends ""
      ? true
      : AreDigits<CRest>
    : false
  : false;
  1. Build up higher-level predicates.
type IsFloat<S extends string> = S extends `${infer L}.${infer T}`
  ? AreDigits<L> extends true
    ? AreDigits<T> extends true
      ? true
      : false
    : false
  : false;

// etc ...

... with this approach, we run into three issues:

  1. We cannot recurse over strings which are greater than 16 chars in length.
  2. The AreDigits and HasDecimal types aren't portable––aka., library developers can't let users supply their own predicates to library-exposed generic types.
  3. Predicates can't be used within patterns.

I'm curious why the solution is new syntax, instead of solutions to those three sub-problems?

For instance:

type Src = "Hello 254.931";

type FloatPlaceholder = _<IsFloat>;

type MatchResult = Src extends `${infer L}${FloatPlaceholder}`
  ? Src extends `${L}${infer Float}`
    ? Float
    : never;
   : never;

declare const matchResult: MatchResult; // `"254.931"`

Here, the underscore is an intrinsic utility type, which accepts a boolean-returning generic type. The result can then be used within a pattern.

Seems this approach might take less effort to grok.

Then again, I'd imagine there are performance considerations of which this is lacking.

Still, I'd love to hear about why conditional assignability is the preferred route!

@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 23, 2020

@harrysolovay ATM, they don't have any official HKT support (though I would love for them to add this). I think the approach they're considering taking is basically the same as yours, but with a syntactical approach rather than a utility type approach. With the syntactic appraoch, you could write type FloatPlaceholder = match S extends string => IsFloat<S>, and, with HKT support, you could also write the utility type (type _<T<U>> = match U extends any => T<U>).

While I definitely think they should add HKTs (which could "easily" be accomplished with a Call<TFunc, TParams> utility, like in Flow), IMO the conditional assignability is too complex / (different from everything else) to be magicked away in an intrinsic type.

Also, is there another issue for the conditional assignability that should be commented upon, rather than cluttering up these meeting notes?

@harrysolovay
Copy link

Not sure if this conversation calls for another issue, or if it should exist in Discord, or when / if GitHub discussions will be enabled for this repo. Hopefully a TS team member can provide guidance as to what is the correct forum for this discussion. Until then, I doubt we're imposing by commenting here (although sorry if we are!).


While fully-fledged HKTs would be nice, that's not what I'm suggesting. Yes, it's similar in the sense that there's some deferred evaluation of generic types. Yet there's a more-specific way to satisfy this use case: the ability to turn a string-accepting, boolean-producing generic type into a pattern placeholder.

Even if we had this ability, conditional assignability may be more ergonomic and preferable.

@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 23, 2020

While fully-fledged HKTs would be nice, that's not what I'm suggesting. Yes, it's similar in the sense that there's some deferred evaluation of generic types.

I don't think "deferred evaluation of generic types" actually has any meaning in typescript as it stands; you can't use generic types as types without supplying the type parameters.

Yet there's a more-specific way to satisfy this use case: the ability to turn a string-accepting, boolean-producing generic type into a pattern placeholder.

I don't think these were supposed to be limited to strings; I think that the use cases are just more obvious with strings.

Regardless, I think it would be a significant anti-pattern to have HKT-esque support for one specific built in utility type (or HKT support only for returning booleans). If they are resistant to adding general HKT support, it can be syntactic (as they've been discussing), similar to how they've implemented e.g. object maps.

I think I may be misunderstanding what you are suggesting; are you suggesting an alternative to conditional assignability, or an alternate syntax for it?

@harrysolovay
Copy link

@tjjfvi thank you for pointing out that this is not limited to string literals.

I'm suggesting new syntax may not be necessary.

type Digit = match S extends string =>
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;

vs.

type IsDigit<S extends string> =
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;

type Digit = ConditionalAssignability<IsDigit>;

Users are already familiar with generic and conditional types. I'm curious why introducing new syntax is necessary? Why not just introduce an intrinsic utility type for turning a predicate such as IsDigit into its conditional-assignability counterpart?

On another note, I'd like to know more about what this means?:

But you can only trigger the inference on these type aliases for literals by writing them in call positions.

Does this mean that one cannot pass a conditional assignability to a generic type? Aka., is the following invalid?:

type Digit = match S extends string =>
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;

type MeetsConstraint<E, C> = E extends C ? "The constraint `C` is met!" : "No!";

type Result = MeetsConstraint<"1", Digit>; // expecting "The constraint `C` is met!" 

If so, that would be unfortunate.

@tjjfvi
Copy link
Contributor

tjjfvi commented Oct 26, 2020

Another random thought on this proposal:

AFACT, the way this is written, conditionally assignable types could violate the "axiom" that if A is assignable to X and B is assignable to X, then A | B is assignable to X.

type IsUnion<T> = (T extends any ? (x: (x: T) => T) => any : never) extends (x: infer U) => any ? U extends (x: any) => infer V ? [T] extends [V] ? true : false : false : false;
type NonUnion<T = any> = match U extends T => IsUnion<U> extends true ? false : true;

type TestAssignablility<A, B> = A extends B ? true : false;

// Equivalent to `[true, true, false]`?
type Test = [TestAssignability<"A", NonUnion>, TestAssignability<"B", NonUnion>, TestAssignability<"A" | "B", NonUnion>];

Do any other types currently behave this way?

@harrysolovay
Copy link

@DanielRosenwasser I'm sad to see that this issue has been closed. I don't mean to impose by asking, but I'm curious if/why the team isn't pursuing conditional assignability. To me, it seemed like an extraordinary feature that would open countless doors to future DXs.

@tjjfvi
Copy link
Contributor

tjjfvi commented May 26, 2021

@harrysolovay DanielRosenwasser just closed 49 design note issues, so I doubt it was anything specific.

@DanielRosenwasser
Copy link
Member Author

Yeah, but also is this what you're looking for? #30639

@tjjfvi
Copy link
Contributor

tjjfvi commented May 26, 2021

@DanielRosenwasser No, I think they were referring to the "Conditional Assignability" mentioned in the design notes

// Concept
type Digit = match S extends string =>
  S extends '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' ? true : false;

@tpict
Copy link

tpict commented Oct 22, 2021

Is the "conditional assignability" concept being tracked anywhere? Still have my fingers crossed that we can get some of TypeScript's inference capabilities in assignment operations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Notes Notes from our design meetings
Projects
None yet
Development

No branches or pull requests

7 participants