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

Relate control flow to conditional types in return types #33912

Open
5 tasks done
RyanCavanaugh opened this issue Oct 9, 2019 · 43 comments · May be fixed by #56941
Open
5 tasks done

Relate control flow to conditional types in return types #33912

RyanCavanaugh opened this issue Oct 9, 2019 · 43 comments · May be fixed by #56941
Assignees
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Oct 9, 2019

Search Terms

control flow conditional return type cannot assign extends

Suggestion

Developers are eager to use conditional types in functions, but this is unergonomic:

type Person = { name: string; address: string; };
type Website = { name: string; url: URL };
declare function isWebsite(w: any): w is Website;
declare function isPerson(p: any): p is Person;

function getAddress<T extends Person | Website>(obj: T): T extends Person ? string : URL {
  if (isWebsite(obj)) {
    // Error
    return obj.url;
  } else if (isPerson(obj)) {
    // Another error
    return obj.address;
  }
  throw new Error('oops');
}

The errors here originate in the basic logic:

obj.url is a URL, and a URL isn't a T extends Person ? string : URL

By some mechanism, this function should not have an error.

Dead Ends

The current logic is that all function return expressions must be assignable to the explicit return type annotation (if one exists), otherwise an error occurs.

A tempting idea is to change the logic to "Collect the return type (using control flow to generate conditional types) and compare that to the annotated return type". This would be a bad idea because the function implementation would effectively reappear in the return type:

function isValidPassword<T extends string>(s: T) {
  if (s === "very_magic") {
    return true;
  }
  return false;
}

// Generated .d.ts
function isValidPassword<T extends string>(s: T): T extends "very_magic" ? true : false;

For more complex implementation bodies, you could imagine extremely large conditional types being generated. This would be Bad; in most cases functions don't intend to reveal large logic graphs to outside callers or guarantee that that is their implementation.

Proposal Sketch

The basic idea is to modify the contextual typing logic for return expressions:

type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
    if (typeof arg === "string") {
        return 1;
    } else {
        return -1;
    }
}

Normally return 1; would evaluate 1's type to the simple literal type 1, which in turn is not assignable to SomeConditionalType<T>. Instead, in the presence of a conditional contextual type, TS should examine the control flow graph to find narrowings of T and see if it can determine which branch of the conditional type should be chosen (naturally this should occur recursively).

In this case, return 1 would produce the expression type T extends string ? 1 : never and return -1 would produce the expression type T extends string ? never : -1; these two types would both be assignable to the declared return type and the function would check successfully.

Challenges

Control flow analysis currently computes the type of an expression given some node in the graph. This process would be different: The type 1 does not have any clear relation to T. CFA would need to be capable of "looking for" Ts to determine which narrowings are in play that impact the check type of the conditional.

Limitations

Like other approaches from contextual typing, this would not work with certain indirections:

type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
    let n: -1 | 1;
    if (typeof arg === "string") {
        n = 1;
    } else {
        n = -1;
    }
    // Not able to detect this as a correct return
    return n;
}

Open question: Maybe this isn't specific to return expressions? Perhaps this logic should be in play for all contextual typing, not just return statements:

type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
    // Seems to be analyzable the same way...
    let n: SomeConditionalType<T>;
    if (typeof arg === "string") {
        n = 1;
    } else {
        n = -1;
    }
    return n;
}

Fallbacks

The proposed behavior would have the benefit that TS would be able to detect "flipped branch" scenarios where the developer accidently inverted the conditional (returning a when they should have returned b and vice versa).

That said, if we can't make this work, it's tempting to just change assignability rules specifically for return to allow returns that correspond to either side of the conditional - the status quo of requiring very unsafe casts everywhere is not great. We'd miss the directionality detection but that'd be a step up from having totally unsound casts on all branches.

Use Cases / Examples

TODO: Many issues have been filed on this already; link them

Workarounds

// Write-once helper
function conditionalProducingIf<LeftIn, RightIn, LeftOut, RightOut, Arg extends LeftIn | RightIn>(
    arg: Arg,
    cond: (arg: LeftIn | RightIn) => arg is LeftIn,
    produceLeftOut: (arg: LeftIn) => LeftOut,
    produceRightOut: (arg: RightIn) => RightOut):
    Arg extends LeftIn ? LeftOut : RightOut
{
    type OK = Arg extends LeftIn ? LeftOut : RightOut;
    if (cond(arg)) {
        return produceLeftOut(arg) as OK;
    } else {
        return produceRightOut(arg as RightIn) as OK;
    }
}

// Write-once helper
function isString(arg: any): arg is string {
    return typeof arg === "string";
}

// Inferred type
// fn: (arg: T) => T extends string ? 1 : -1
function fn<T>(arg: T) {
    return conditionalProducingIf(arg, isString,
        () => 1 as const,
        () => -1 as const);
}

let k = fn(""); // 1
let j = fn(false); // -1

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
    • All of these are errors at the moment
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.
@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Oct 9, 2019
@jack-williams
Copy link
Collaborator

jack-williams commented Oct 9, 2019

Control flow analysis currently computes the type of an expression given some node in the graph. This process would be different: The type 1 does not have any clear relation to T. CFA would need to be capable of "looking for" Ts to determine which narrowings are in play that impact the check type of the conditional.

FWIW: this is exactly the same problem faced here in #33014.


That said, if we can't make this work, it's tempting to just change assignability rules specifically for return to allow returns that correspond to either side of the conditional - the status quo of requiring very unsafe casts everywhere is not great. We'd miss the directionality detection but that'd be a step up from having totally unsound casts on all branches.

Can users not just use an overload to emulate this today?

type SomeConditionalType<T> = T extends string ? 1 : -1;

function fn<T>(arg: T): SomeConditionalType<T>;
function fn<T>(arg: T): 1 | -1 {
    if (typeof arg === "string") {
        return 1;
    } else {
        return -1;
    }
}

Is it possible to cleanly handle the following, without rebuilding the CFG on demand in the checker?

function fn<T>(arg: T): SomeConditionalType<T> {
    return typeof arg === "string" ? 1 : -1;   
}

@jcalz
Copy link
Contributor

jcalz commented Oct 10, 2019

If generic type parameters could be narrowed via control flow analysis then perhaps this would also address #13995?

@jack-williams
Copy link
Collaborator

I don't think this is really a solution to type parameter narrowing in general. There are ways that this could be unsound because of the previously discussed points where a type guard doesn't provide sufficient information to narrow a type-variable. Two examples:

// Example 1.
type HasX = { x: number }
function hasX(value: unknown): value is HasX {
    return typeof value === "object" && value !== null && typeof (<any>value).x === "number"
}

function foo<T>(point: T): T extends HasX ? number : boolean {
    if (hasX(point)) {
        return point.x
    }
    return false;
}

const point: { x: number | boolean } = { x: 3 };
const b: boolean = foo(point);

// Example 2.
type SomeConditionalType<T> = T extends string ? 1 : -1;
function fn<T>(arg: T): SomeConditionalType<T> {
    if (typeof arg === "string") {
        return 1;
    } else {
        return -1;
    }
}

const shouldBe1: -1 = fn("a string" as unknown);
const isOne: 1 = fn("a string");
const isOneMaybe: 1 | - 1 = fn("a string" as string | number);

I think the solution is relying on two points: the conditional type is distributive, and the constraint of the check type is 'filterable' with respect to the extends type in each conditional type. That is:

Given a type parameter T extends C, and a conditional type T extends U ? A : B, then for all types V:

  • if V is assignable to C, and U and V are not disjoint
  • then there is a filtering of V that is assignable to U.

@jcalz
Copy link
Contributor

jcalz commented Nov 12, 2019

Cross-linking to #22735, the design limitation addressed by this suggestion

@Birowsky
Copy link

Birowsky commented Jan 6, 2020

Just an SO question as another reference.

@dgreensp
Copy link

dgreensp commented Jan 9, 2020

As proposed, I think this goes too far in the direction of unsoundness. Knowing that a value arg of type T is a string doesn't tell you that T extends string, it actually tells you that T is a supertype of string! This proposal would basically have the compiler analyze your code to see if it might be doing something correct, and then remove errors and the need for casts, which yes does achieve backwards compatibility and reduced errors and casts, but at a high cost in being able to trust the compiler's judgment.

I think a good starting point would be an example function that is self-consistent in terms of the relationship between its argument types and return type. If fn<string>("x") returns 1 and fn<unknown>("x") returns -1 at the type level, something is already wrong, because a function can't return 1 and -1 for the same input value.

How about something like:

function getTypeCode<T extends number | string>(
  t: T
): T extends number
  ? 'N'
  : T extends string
  ? 'S'
    : 'N' | 'S' {
    if (typeof t === 'number') {
        return 'N'
    } else if (typeof t === 'string') {
        return 'S'
    }
  }

Here each conditional specializes the output for a specialized input, like a type-safe overload. Analysis should proceed starting from the return type. If T extends number, does the function return 'N'? If the compiler can prove that, bingo.

@RyanCavanaugh
Copy link
Member Author

@dgreensp I don't believe the proposal in the OP introduces any novel unsoundness, specifically because the logic is only applied to return expressions which are strictly a covariant position. Can you show an example?

@jack-williams
Copy link
Collaborator

I think my examples here show unsound calls.

@dgreensp
Copy link

dgreensp commented Jan 9, 2020

@RyanCavanaugh In your example:

function fn<T>(arg: T): T extends string ? 1 : -1 {
    if (typeof arg === "string") {
        return 1;
    } else {
        return -1;
    }
}

Substituting unknown for T:

function fn(arg: unknown): -1 {
    if (typeof arg === "string") {
        return 1; // error
    } else {
        return -1;
    }
}

Or substituting string | number for T:

function fn(arg: string | number): -1 {
    if (typeof arg === "string") {
        return 1; // error
    } else {
        return -1;
    }
}

So I think the compiler should not allow the example. It would be great to design this feature so that all valid substitutions for T produce valid typings, first, and only be more lenient if that proves too limiting. Fundamentally, all the compiler can prove at the site of return 1 is that arg is a T and arg is a string. It does not follow that T extends string, so there's no single branch of the conditional that applies to this return statement. (The else branch that returns -1 is a different story, as described below, because if T does extend string, then it is unreachable.)

In my example, the same substitutions are correct, for example substituting unknown for T:

function getTypeCode(
  t: unknown
): 'N' | 'S' {
  if (typeof t === 'number') {
      return 'N'
  } else if (typeof t === 'string') {
      return 'S'
  }
}

A safe alternative proposal would be... I think, first allow returning a value that's assignable to all the branches of the conditional return value (or the intersection of the branches). So in a function whose return value is A extends B ? C : D, you can always return a value of type C & D. Then omit C at those return sites that are unreachable if A is replaced by B.

In other words, the contextual return type is D in places that are unreachable if A is narrowed to B, and C & D otherwise.

So in the original example:

function fn<T>(arg: T): T extends string ? 1 : -1 {
    if (typeof arg === "string") {
        // contextual type is 1 & -1, so this is an error
        return 1;
    } else {
        // this branch is unreachable in the positive case where T extends string,
        // so contextual type is -1, and this is ok
        return -1;
    }
}

Code that is crafted to be "correct," like my example, will work, as follows:

function getTypeCode<T extends number | string>(
  t: T
): T extends number
  ? 'N'
  : T extends string
  ? 'S'
  : 'N' | 'S' {
  if (typeof t === 'number') {
      // Unreachable when T extends string, so type is 'N' & ('N' | 'S') = 'N'
      return 'N'
  } else if (typeof t === 'string') {
      // Unreachable when T extends number so type is 'S' & ('N' | 'S') = 'S'
      return 'S'
  }
}

@dgreensp
Copy link

dgreensp commented Jan 10, 2020 via email

@simonbuchan
Copy link

Overloads work for the original example:

function getAddress(obj: Website): URL;
function getAddress(obj: Person): string;
function getAddress(obj:  Website | Person) {
  if (isWebsite(obj)) {
    return obj.url; // no error, error on the overload if the type is changed here
  } else if (isPerson(obj)) {
    return obj.address; // same
  }
  throw new Error('oops');
}

What are they doing different?

@arobinson
Copy link

Here is another use case where it seems to be the same issue:
https://stackblitz.com/edit/rxjs-wdvejf?file=index.ts

Here a conditional type bound to a method argument and return value are unable to handle the valid cast

type AddressBookEntry<T> = T extends AddressBookEntryType.Business
  ? Business
  : T extends AddressBookEntryType.Person
  ? Person
  : never;

const addressBookMap: Map<
  AddressBookEntryType,
  Map<string, AddressBookEntry<any>>
> = new Map();

const getFromMap = <T extends AddressBookEntryType>(
  key: string,
  entryType: T
): AddressBookEntry<T> => {
  const byTypeMap = addressBookMap.get(entryType);
  if (byTypeMap !== null) {
    return byTypeMap.get(key); // <-- Error:
  // Type 'Business | Person' is not assignable to type 'AddressBookEntry<T>'.
  // Type 'Business' is not assignable to type 'AddressBookEntry<T>'.(2322)
  } else {
    return null;
  }
};

@webia1
Copy link

webia1 commented Apr 4, 2021

My workaround (in a simpler example from the official documentation) is explicitly casting to the expected type. Even if it is not an elegant solution and requires an intermediate step with <unknown>, the compiler remains silent:

type IdLabel = { id: number };
type NameLabel = { name: string };

type NameOrIdType<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

function createLabel<T extends number | string>(
  paramNameOrId: T,
): NameOrIdType<T> {
  if (typeof paramNameOrId === 'string') {
-  return { name: paramNameOrId };
+  return <NameOrIdType<T>>(<unknown>{ name: paramNameOrId });
  } else {
-   return { id: paramNameOrId };
+   return <NameOrIdType<T>>(<unknown>{ id: paramNameOrId });
  }
}

let a = createLabel('typescript'); // { name: 'typescript' }
let b = createLabel(4.1); // { id: 4.1 }

@saiichihashimoto
Copy link

image

From the documentation specifically on conditional types, it looks like something like this not only should be possible, but a main use case. The same documentation uses this as a replacement for overloads but, from this issue, it looks like there's no other way. In the documented use case, how would one implement createLabel without extra casts?

@Qtax
Copy link

Qtax commented Sep 20, 2021

Overloads (like in @simonbuchan example) is exactly what I would like to do, but with arrow functions - but unfortunately it seems you can't use overloads with arrow functions, at least what I have found so far, without casting the implementation return type to any.

type Overload = {
    (obj: Website): URL;
    (obj: Person): string;
}

const getAddress: Overload = (obj): any /* any required here :-( */ => {
    if (isWebsite(obj)) {
        return obj.url;
    } else if (isPerson(obj)) {
        return obj.address;
    }
    throw new Error('oops');
}

Playground Link

@schuelermine
Copy link

I don't see how the unwieldy inferred return types are a problem. Couldn't you just infer a naïve type but check if an explicit return type annotation exists?

Also, I don't think it's necessary to change the inferred type of the return value, but to, when checking the return type, evaluate the conditional type given the known guards in the control flow.

@Amareis
Copy link

Amareis commented Feb 1, 2023

@ehaynes99

To say that a function has conditional return types is the same as saying it has multiple signatures, otherwise known as an overload.

Yes, but also conditional return type can branch on some external type - for example, methods in generic class can relate to class type parameter, and not to a function arguments. So it's slightly wider than just overloads. And overloads is wider than conditional return type too, since you can express invariants like "all arguments and return type is string, or all of them is number", which is... Can be written via generic functions, of course, but here is much more variants which you can easily express with overloads, just like "it's a single number argument, returning number or string and some options, still returning number".

Also, your original example now correctly infer type of arguments, but still not compiling, exactly because implementation signature isn't compatible with overloads signatures. And in overloaded function, types of arguments isn't inferred, so some inconsistency here... But I still think that issues with overloaded functions is slightly different case (but definitely somehow-related to this issue). So I fill issue with some discussions around it here #52478

I think, correct and type-safe overloads can handle lion share of cases for this issue, and still much easier to implement.

@ehaynes99
Copy link

nit: as you wrote it, the overload function would reject an argument of type Person | Website whereas the generic version would accept it. It needs an additional overload.

Correct you are, but that case already works. :-D

@ehaynes99
Copy link

Yes, but also conditional return type can branch on some external type - for example, methods in generic class can relate to class type parameter, and not to a function arguments.

There are no "external types", only types that are in scope:

// same issue
function getAddress<T extends Person | Website>(obj: T) {
  return function inner() {
    return function inner2() {
      return function inner3() {
        return function inner4(): T extends Person ? string : URL {

But I still think that issues with overloaded functions is slightly different case (but definitely somehow-related to this issue). So I fill issue with some discussions around it here #52478

I completely agree that all of the different cases you're bringing up are different cases. :-) All conditional return types are overloads, but not all overloads have conditional return types. You're bringing up a lot of the second case, and those aren't related to this issue.

You opened an issue about overloads, cited my comment as support for it, and then came here to say that it's not the same case as overloads. For the record, I don't care at all about the semantics of the existing overload syntax. It's an anti-feature like enum that I could dispense with entirely if we had a solution that allowed a type definition for overloads independent of implementation. const assertions allows deprecation of enum, and this would allow deprecation of the current version of overloads.

I think, correct and type-safe overloads can handle lion share of cases for this issue, and still much easier to implement.

You've said "easy to implement" a dozen times or so. When people who actively maintain the compiler on a daily basis are telling you otherwise, you're going to need to produce the PR for anyone to believe you.

I don't want to pollute this thread anymore. If you disagree, you can post it in this gist, but you really need to enumerate your cases, because pointing out that there are a lot of different cases without listing them is a poor preface to calling something easy.

@margaretdax
Copy link

Hey @gabritto any update on this? Honestly one of my biggest pains working w/ TypeScript has been these sorts of weird edge cases in the type system. Also, for this example in the docs, it is still not possible to write an implementation that does not require type assertions.

@gabritto
Copy link
Member

gabritto commented Nov 21, 2023

@margaretdax I am slowly working on it. I'm close to having a working prototype, but it's been tricky to make it work in a mostly sound way.
Meanwhile, real world examples of where you'd want to use this feature are more than welcome.

@timotheeandres
Copy link

@margaretdax I am slowly working on it. I'm close to having a working prototype, but it's been tricky to make it work in a mostly sound way.
Meanwhile, real world examples of where you'd want to use this feature are more than welcome.

Hello! I think I have a relevant example here:
https://stackoverflow.com/questions/77612251/typescript-conditional-type-depending-on-whether-type-is-undefined

@gabritto
Copy link
Member

gabritto commented Dec 6, 2023

Hello! I think I have a relevant example here: https://stackoverflow.com/questions/77612251/typescript-conditional-type-depending-on-whether-type-is-undefined

Thanks for the example, it has a couple things that don't currently work with my in-progress prototype, but that I hope I can make work.

@ehaynes99
Copy link

Here's another, similar to something I recently did around RabbitMQ. Depending on the type of the exchange, I want the signature of a publish function to be different (Rabbit just being an example, so mocking it here):

Playground Link:

type FauxAmqpChannel = {
  publish(exchange: string, routingKey: string, content: string, options?: Record<string, any>): Promise<void>
}

type DirectExchange = { type: 'direct'; name: string }

type DirectPublisher<T> = (routingKey: string, message: T) => Promise<void>

type FanoutExchange = { type: 'fanout'; name: string }

type FanoutPublisher<T> = (message: T) => Promise<void>

type CreatePublisherFn = {
  <T>(channel: FauxAmqpChannel, directExchange: DirectExchange): DirectPublisher<T>
  <T>(channel: FauxAmqpChannel, fanoutExchange: FanoutExchange): FanoutPublisher<T>
}

// Type '(routingKey: string, message: T) => Promise<void>' is not assignable to type 'FanoutPublisher<any>'.
//   Target signature provides too few arguments. Expected 2 or more, but got 1. [2322]
const createPublisher: CreatePublisherFn = (channel, exchange) => {
  if (exchange.type === 'direct') {
    return async (routingKey, message) => {
      await channel.publish(exchange.name, routingKey, JSON.stringify(message))
    }
  } else if (exchange.type === 'fanout') {
    return async (message) => {
      await channel.publish(exchange.name, '', JSON.stringify(message))
    }
  }
  throw new TypeError(`Unknown exchange type: ${exchange}`)
}

declare const channel: FauxAmqpChannel

const directPublisher = createPublisher<string>(channel, { type: 'direct', name: 'direct-exchange' })

const fanoutPublisher = createPublisher<string>(channel, { type: 'fanout', name: 'fanout-exchange' })

The problem is further compounded if you want varying numbers of generic parameters. RabbitMQ has a "topic exchange" that uses structured strings as keys, so it would be nice to be able to strongly type those. The compiler loses all ability to infer input or output types at that point.

Playground Link:

type FauxAmqpChannel = {
  publish(exchange: string, routingKey: string, content: string, options?: Record<string, any>): Promise<void>
}

type DirectExchange = { type: 'direct'; name: string }

type DirectPublisher<T> = (routingKey: string, message: T) => Promise<void>

type FanoutExchange = { type: 'fanout'; name: string }

type FanoutPublisher<T> = (message: T) => Promise<void>

export type TopicExchange<K> = { type: 'topic'; name: string; createKey: (key: K) => string }

export type TopicPublisher<T, K> = (key: K, message: T) => Promise<void>

export type CreatePublisherFn = {
  <T>(channel: FauxAmqpChannel, directExchange: DirectExchange): DirectPublisher<T>
  <T>(channel: FauxAmqpChannel, fanoutExchange: FanoutExchange): FanoutPublisher<T>
  <T, K>(channel: FauxAmqpChannel, topicExchange: TopicExchange<K>): TopicPublisher<T, K>
}

const createPublisher: CreatePublisherFn = (channel, exchange) => {
  if (exchange.type === 'direct') {
    return async (routingKey, message) => {
      await channel.publish(exchange.name, routingKey, JSON.stringify(message))
    }
  } else if (exchange.type === 'fanout') {
    return async (message) => {
      await channel.publish(exchange.name, '', JSON.stringify(message))
    }
  } else if (exchange.type === 'topic') {
    return async (key, message) => {
      const routingKey = exchange.createKey(key)
      await channel.publish(exchange.name, routingKey, JSON.stringify(message))
    }
  }
  throw new TypeError(`Unknown exchange type: ${exchange}`)
}

declare const channel: FauxAmqpChannel

const directPublisher = createPublisher<string>(channel, { type: 'direct', name: 'direct-exchange' })

const fanoutPublisher = createPublisher<string>(channel, { type: 'fanout', name: 'fanout-exchange' })

That case probably makes this more difficult, but it is covered with standard overloading. This is perfectly valid:

type VariablyGeneric = {
  <T>(values: T[]): string
  <K extends string, T>(values: Record<K, T>): string
}

const variablyGeneric: VariablyGeneric = (values: any) => {
  if (Array.isArray(values)) {
    return values.join(', ')
  }
  return Object.values(values).join(', ')
}

@mtinner
Copy link

mtinner commented Jul 11, 2024

Another overloading without usage of any or type assertion:

type Person = { name: string; address: string; };
type Website = { name: string; url: URL };
declare function isWebsite(w: any): w is Website;
declare function isPerson(p: any): p is Person;

function getAddress<T extends ( Person | Website)>(obj: T): T extends Person ? string : URL
function getAddress<T>(obj: T) {
    if (isWebsite(obj))
        return obj.url
    else if (isPerson(obj))
       return obj.address
    throw new Error('oops');
}

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