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, 3/8/2022 #53169

Closed
DanielRosenwasser opened this issue Mar 8, 2023 · 7 comments
Closed

Design Meeting Notes, 3/8/2022 #53169

DanielRosenwasser opened this issue Mar 8, 2023 · 7 comments
Labels
Design Notes Notes from our design meetings

Comments

@DanielRosenwasser
Copy link
Member

Using a Homomorphic Mapped Type For Omit

#53134

interface Obj {
    readonly a: string;
    b?: number;
    c: boolean;
}

// "homomorphic" - preserves all modifiers in BoxedObj
type Boxify<T> = {
    [K in keyof T]: { value: T[K] }
}
type BoxedObj = Boxify<Obj>;


// non-"homomorphic" - loses information for BoxedObj2
type Boxify2<Keys extends keyof Obj> = {
    [K in Keys]: { value: Obj[K] }
}
type BoxedObj2 = Boxify2<keyof Obj>;
  • Today, Omit is non-homomorphic. Will not preserve shape.

    • Why?
    • keyof on its own does not retain its source type.
  • Today, we can write Omit with an as clause with Exclude - still considered "homomorphic" (even though at this point "homomorphic" is a misnomer).

    type Omit<T, DroppedKeys extends PropertyKey> = {
        [K in keyof T as Exclude<K, DroppedKeys>]: T[K]
    };
  • Switching Omit at this point would likely be breaky.

    • Existing code where interfaces extends from Omit
  • Using a "homomorphic" Omit makes code flow work better because it can produce union types.

  • Could choose to have Omit (original) and MappedOmit - or Omit (improved) and LegacyOmit.

    • Swapping Omit to the "better" version is breaky - at least one package breaks.
  • We really don't want to have 2 Omits, and want to push on an improved Omit.

    • Existing code can write Pick<T, Omit<keyof T, DroppedKeys>>
  • Wait, should Pick be fixed to use a homomorphic mapped type too?

    • OH NOOOOOOOOOOOOOOOOOOOOOOOOOOOO
    • Almost had a slam dunk on just fixing Omit.
  • If you fix Pick, then Omit defined as Pick<T, Exclude<keyof T, DroppedKeys>> is also homomorphic, right?

    • Almost - but Omit is not going to distribute, when we write Exclude<keyof T, DroppedKeys>
  • So each of them need to be written as their own mapped types written in terms of keyof T.

  • Note: the as clause for Pick should use an intersection, not Extract

    • Why?
      • Intersections are much faster for unions of literals.
    • What's the difference in the general case?
      • For literal types, they're the same.
      • For object types and generic types, intersections combine and don't always "eradicate" other types to never.
@DanielRosenwasser DanielRosenwasser added the Design Notes Notes from our design meetings label Mar 8, 2023
@Andarist
Copy link
Contributor

Andarist commented Mar 8, 2023

Reads like a good novel. It has everything:

  • excitation
  • fear of unknown
  • turn of events
  • suspense

@juanrgm
Copy link

juanrgm commented Mar 10, 2023

The problem with the "homomorphic" version of Omit is that we lose the docblock and the goto feature:

Example

type Omit2<T, DroppedKeys extends PropertyKey> = {
    [K in keyof T as Exclude<K, DroppedKeys>]: T[K];
};

function omit2<O, Mask extends { [K in keyof O]?: true }>(
    _object: O,
    _mask: Mask
): Omit2<O, keyof Mask> {
    throw new Error("Not implemented");
}

const object2 = omit2(object,
    {
        name: true,
    }
);

object2.id // ❌ goto/docblock

@Andarist
Copy link
Contributor

Hm, I expected that to work in 5.0 since #51650 . It doesn't though... I'll investigate what's happening there

@Andarist
Copy link
Contributor

It seems that my fix (#51650) had a bug. Accidentally, my other PR already fixes this but since its fate is still not determined, I will prepare a separate PR fixing this tomorrow.

@Andarist
Copy link
Contributor

Andarist commented Feb 7, 2024

I was often confused about this in the past too. In reality, mapped types are not split into two groups though (homomorphic and non-homomorphic). They are split into 3: homomorphic, non-homomorphic with non-unknown modifiers type and non-homomorphic with an unknown moifiers type.

Homomorphic mapped types preserve potential arrayness of the input, they distribute over unions and they preserve original modifiers. So in a sense, homomorphic mapped types are a subset of the mapped typed with non-unknown modifiers type.

I might be using this lingo slightly wrong (so don't quote me on that) but that's roughly how things are called under the hood (there is no specific name for a mapped type with a (non-)unknown modifiers type though, it's just something that is checked when "transferring" the modifiers on the output type etc).

@jcalz
Copy link
Contributor

jcalz commented Feb 7, 2024

Okay, so "non-homomorphic with known modifiers" is specifically what you get when you write {[P in K]: ⋯} where K extends keyof T for some generic T?

@Andarist
Copy link
Contributor

Andarist commented Feb 7, 2024

Yes, that's one way to end up with this "type".

getModifiersTypeFromMappedType in the source code is defined as follows:

function getModifiersTypeFromMappedType(type: MappedType) {
    if (!type.modifiersType) {
        if (isMappedTypeWithKeyofConstraintDeclaration(type)) {
            // If the constraint declaration is a 'keyof T' node, the modifiers type is T. We check
            // AST nodes here because, when T is a non-generic type, the logic below eagerly resolves
            // 'keyof T' to a literal union type and we can't recover T from that type.
            type.modifiersType = instantiateType(getTypeFromTypeNode((getConstraintDeclarationForMappedType(type) as TypeOperatorNode).type), type.mapper);
        }
        else {
            // Otherwise, get the declared constraint type, and if the constraint type is a type parameter,
            // get the constraint of that type parameter. If the resulting type is an indexed type 'keyof T',
            // the modifiers type is T. Otherwise, the modifiers type is unknown.
            const declaredType = getTypeFromMappedTypeNode(type.declaration) as MappedType;
            const constraint = getConstraintTypeFromMappedType(declaredType);
            const extendedConstraint = constraint && constraint.flags & TypeFlags.TypeParameter ? getConstraintOfTypeParameter(constraint as TypeParameter) : constraint;
            type.modifiersType = extendedConstraint && extendedConstraint.flags & TypeFlags.Index ? instantiateType((extendedConstraint as IndexType).type, type.mapper) : unknownType;
        }
    }
    return type.modifiersType;
}

whereas isMappedTypeHomomorphic is just this:

function isMappedTypeHomomorphic(type: MappedType) {
    return !!getHomomorphicTypeVariable(type);
}

and getHomomorphicTypeVariable is this:

function getHomomorphicTypeVariable(type: MappedType) {
    const constraintType = getConstraintTypeFromMappedType(type);
    if (constraintType.flags & TypeFlags.Index) {
        const typeVariable = getActualTypeVariable((constraintType as IndexType).type);
        if (typeVariable.flags & TypeFlags.TypeParameter) {
            return typeVariable as TypeParameter;
        }
    }
    return undefined;
}

Sorry for quoting a bunch of source code but I feel like it's just the only way to answer this precisely ;p Note that an IndexType means keyof Something. It's also useful to inspect mapped type declaration in the https://ts-ast-viewer.com/# to learn more about how specific parts of it might be called (what is its constraint, type parameter, etc)

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

5 participants