Skip to content

Design Meeting Notes, 3/8/2022 #53169

Description

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.

Activity

  1. Andarist commented on Mar 8, 2023

    @Andarist
    Contributor

    Reads like a good novel. It has everything:

    • excitation
    • fear of unknown
    • turn of events
    • suspense
  2. juanrgm commented on Mar 10, 2023

    @juanrgm

    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
  3. Andarist commented on Mar 10, 2023

    @Andarist
    Contributor

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

  4. Andarist commented on Mar 11, 2023

    @Andarist
    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.

  5. Andarist commented on Feb 7, 2024

    @Andarist
    Contributor

    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).

  6. jcalz commented on Feb 7, 2024

    @jcalz
    Contributor

    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?

  7. Andarist commented on Feb 7, 2024

    @Andarist
    Contributor

    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

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