Skip to content

Design Meeting Notes, 2026-09-01 #64129

Description

Conditional Distributions Allow Constraint Violation

#63708

type Issue<A, B extends A> =
    A extends unknown ?
        B extends unknown ?
            Show<A, B> :
        never :
    never;

type Show<A, B> = [A, B] & {};

type X = Issue<0 | 1, 0 | 1>;
//   ^?
  • The problem is that when we distribute on A and B, then we should create a new type parameter for each one with an identical name.

  • So we are prototyping this.

  • This is a little complicated by the fact that this now also becomes a declaration site rather than just a usage.

    • We would need to introduce something in the binder for this.
    • So that would say "this is a distribution site with a new type variable".
  • But that would have broader implications beyond just type variables on the left side of extends.

  • Doing something like this would changing the semantics of type distribution because we'd now operate over any identifier-named type.

    // TODO
  • When explaining to others, we've explained this as "there's a new type variable that is created for distribution", so surprised it didn't work this way.

    • Really there's a mix of substitution types and other instantiation mechanics at play here.
  • Do we need substitution types?

    • Yes. You need "learned information"/"conditional narrowing" to be tracked along to satisfy other constraints in true branches.
    • Wait really? Why can't T extends number become T' extends T & number?
  • How does this work over type references that alias bare identifiers

    type Foo<T> = T;
    
    type Blah<T> = Foo<T> extends any ? { b: Foo<T> } : undefined;
    
    type What = Blah<1 | 0>;
    • Today, this does distribute; it no longer would with the suggested change...
    • Oh... that's probably very breaky!
    • Who does this?
      • Someone, guaranteed.
  • Feels like people should at least have a way to distribute explicitly since we might be changing things here?

  • You are binding a new type parameter at the top; there's no reason you couldn't other than efficiency.

    • No - we use substitution types to track learned information and conditional narrowing.
    • There are differences in type comparisons when you have substitution types, and they differ in how they act on type comparisons.
    • So while they feel similar, type parameters with added constraints are treated differently in practice.
    • T'' extends T' & (1 | 2) extends T & number isn't reasoned about quite the same as T'' & (1 | 2) & number.
  • Why don't we just introduce syntax to avoid breaking people?

    • The problem is that we are trying to fix a soundness hole and we want that to be fixed by default.
  • Prototype will tell us what breaks in top 999.

API Bikeshed

import { version, versionMajorMinor } from "typescript";
import * as ts from "typescript/async"; // or sync
import * as ast from "typescript/ast";
  • Currently there's a bunch of subpath exports in the typescript package.
    • ast
    • factory
    • is
    • ...
  • As we prototyped replacements with the new API, we noticed old API usage was annoying to go through different imports.
    • Feedback from early API users is that it's less overwhelming than having one import with everything.
  • One of the things we don't like with the "siloing" is that it kind of draws boundaries that we might be fixing ourselves into.
    • Can't really have a single barrel given the sync/async split.

    • If you want a single barrel, you can do it yourself!

      // ./src/typescript.ts
      
      // Single barrel export for convenience
      export * from "typescript";
      export * from "typescript/ast";
      export * from "typescript/async";
      // package.json
      {
          // ...
          "imports": {
              "#ts": "./dist/typescript.js"
          }
      }
      // Other usage
      import * as ts from "#ts";
  • What about identical types imported through different paths?
    • e.g. ModuleKind comes from both "typescript" and "typescript/async".
    • What do people want from this?
  • Do the names overlap between sync/async modules?
    • Yes, they have identical names.
    • It'd be annoying if they all had an Async postifx.
  • Could we just simplify this all into typescript/async and typescript/sync, and re-export common stuff from both?
    • What do you do if you have a function that acts only on the common stuff?
    • Any AST walker.
  • Aside: do we have forEachChildAsync?
    • Currently a problem because forEachChild short-circuits on truthy results. Promises are always truthy, so a walk always exits early.
  • If we had the FIFO prototype resurrected (come back to this Jake Bailey (@jakebailey)?), would people even want async because the context switching is so high?
    • Async is just painful
    • But it's the only way to easily do things concurrently.
    • Wonder how easy it is to use workers and just use the sync layer.
  • Out of time - really need to build some prototypes and get feedback from others.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design NotesNotes from our design meetings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions