TypeScript: Compiled .d.ts output for `Omit` is verbose and semantically inconsistent
TypeScript Version: 3.7.1-rc (but not appreciably different in 3.5.1)
Search Terms: Omit, Pick, verbose, long
Code
// HtmlAttrs.ts
export type InputAttrs = React.InputHTMLAttributes<HTMLInputElement>;
// TextFieldWidget.tsx
import { InputAttrs } from '../../utils/HtmlAttrs';
export interface TextFieldProps {
onChange?: (e: React.ChangeEvent<HTMLInputElement> & { isComposing: boolean }) => void;
}
export const TextFieldWidget = React.forwardRef<HTMLInputElement, TextFieldProps & Omit<InputAttrs, 'onChange'>>((props, ref) => {};
// Output in TextFieldWidget.d.ts
export declare const TextFieldWidget: React.ForwardRefExoticComponent<TextFieldProps & Pick<InputAttrs, "children" | "dir" | "form" | "slot" | "style" | "title" | "hidden" | "pattern" | "disabled" | "defaultChecked" | "defaultValue" | "suppressContentEditableWarning" | "suppressHydrationWarning" | "accessKey" | "className" | "contentEditable" | "contextMenu" | "draggable" | "id" | "lang" | "placeholder" | "spellCheck" | "tabIndex" | "inputMode" | "is" | "radioGroup" | "role" | "about" | "datatype" | "inlist" | "prefix" | "property" | "resource" | "typeof" | "vocab" | "autoCapitalize" | "autoCorrect" | "autoSave" | "color" | "itemProp" | "itemScope" | "itemType" | "itemID" | "itemRef" | "results" | "security" | "unselectable" | "aria-activedescendant" | "aria-atomic" | "aria-autocomplete" | "aria-busy" | "aria-checked" | "aria-colcount" | "aria-colindex" | "aria-colspan" | "aria-controls" | "aria-current" | "aria-describedby" | "aria-details" | "aria-disabled" | "aria-dropeffect" | "aria-errormessage" | "aria-expanded" | "aria-flowto" | "aria-grabbed" | "aria-haspopup" | "aria-hidden" | "aria-invalid" | "aria-keyshortcuts" | "aria-label" | "aria-labelledby" | "aria-level" | "aria-live" | "aria-modal" | "aria-multiline" | "aria-multiselectable" | "aria-orientation" | "aria-owns" | "aria-placeholder" | "aria-posinset" | "aria-pressed" | "aria-readonly" | "aria-relevant" | "aria-required" | "aria-roledescription" | "aria-rowcount" | "aria-rowindex" | "aria-rowspan" | "aria-selected" | "aria-setsize" | "aria-sort" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "dangerouslySetInnerHTML" | "onCopy" | "onCopyCapture" | "onCut" | "onCutCapture" | "onPaste" | "onPasteCapture" | "onCompositionEnd" | "onCompositionEndCapture" | "onCompositionStart" | "onCompositionStartCapture" | "onCompositionUpdate" | "onCompositionUpdateCapture" | "onFocus" | "onFocusCapture" | "onBlur" | "onBlurCapture" | "onChangeCapture" | "onBeforeInput" | "onBeforeInputCapture" | "onInput" | "onInputCapture" | "onReset" | "onResetCapture" | "onSubmit" | "onSubmitCapture" | "onInvalid" | "onInvalidCapture" | "onLoad" | "onLoadCapture" | "onError" | "onErrorCapture" | "onKeyDown" | "onKeyDownCapture" | "onKeyPress" | "onKeyPressCapture" | "onKeyUp" | "onKeyUpCapture" | "onAbort" | "onAbortCapture" | "onCanPlay" | "onCanPlayCapture" | "onCanPlayThrough" | "onCanPlayThroughCapture" | "onDurationChange" | "onDurationChangeCapture" | "onEmptied" | "onEmptiedCapture" | "onEncrypted" | "onEncryptedCapture" | "onEnded" | "onEndedCapture" | "onLoadedData" | "onLoadedDataCapture" | "onLoadedMetadata" | "onLoadedMetadataCapture" | "onLoadStart" | "onLoadStartCapture" | "onPause" | "onPauseCapture" | "onPlay" | "onPlayCapture" | "onPlaying" | "onPlayingCapture" | "onProgress" | "onProgressCapture" | "onRateChange" | "onRateChangeCapture" | "onSeeked" | "onSeekedCapture" | "onSeeking" | "onSeekingCapture" | "onStalled" | "onStalledCapture" | "onSuspend" | "onSuspendCapture" | "onTimeUpdate" | "onTimeUpdateCapture" | "onVolumeChange" | "onVolumeChangeCapture" | "onWaiting" | "onWaitingCapture" | "onAuxClick" | "onAuxClickCapture" | "onClick" | "onClickCapture" | "onContextMenu" | "onContextMenuCapture" | "onDoubleClick" | "onDoubleClickCapture" | "onDrag" | "onDragCapture" | "onDragEnd" | "onDragEndCapture" | "onDragEnter" | "onDragEnterCapture" | "onDragExit" | "onDragExitCapture" | "onDragLeave" | "onDragLeaveCapture" | "onDragOver" | "onDragOverCapture" | "onDragStart" | "onDragStartCapture" | "onDrop" | "onDropCapture" | "onMouseDown" | "onMouseDownCapture" | "onMouseEnter" | "onMouseLeave" | "onMouseMove" | "onMouseMoveCapture" | "onMouseOut" | "onMouseOutCapture" | "onMouseOver" | "onMouseOverCapture" | "onMouseUp" | "onMouseUpCapture" | "onSelect" | "onSelectCapture" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "onPointerDown" | "onPointerDownCapture" | "onPointerMove" | "onPointerMoveCapture" | "onPointerUp" | "onPointerUpCapture" | "onPointerCancel" | "onPointerCancelCapture" | "onPointerEnter" | "onPointerEnterCapture" | "onPointerLeave" | "onPointerLeaveCapture" | "onPointerOver" | "onPointerOverCapture" | "onPointerOut" | "onPointerOutCapture" | "onGotPointerCapture" | "onGotPointerCaptureCapture" | "onLostPointerCapture" | "onLostPointerCaptureCapture" | "onScroll" | "onScrollCapture" | "onWheel" | "onWheelCapture" | "onAnimationStart" | "onAnimationStartCapture" | "onAnimationEnd" | "onAnimationEndCapture" | "onAnimationIteration" | "onAnimationIterationCapture" | "onTransitionEnd" | "onTransitionEndCapture" | "size" | "multiple" | "list" | "step" | "autoFocus" | "type" | "height" | "formAction" | "formEncType" | "formMethod" | "formNoValidate" | "formTarget" | "name" | "value" | "width" | "alt" | "crossOrigin" | "src" | "checked" | "maxLength" | "readOnly" | "accept" | "autoComplete" | "capture" | "max" | "min" | "minLength" | "required"> & React.RefAttributes<HTMLInputElement>>;
Expected behavior:
I expect the .d.ts definition to mimic the structure of the original definition, i.e. to use Omit rather than Pick. In particular, this changes the self-documenting semantic of using Omit. The original definition is intended to make it obvious that the signature of the onChange event is not the standard definition from React, but an augmented type.
BTW – there are more TextFieldProps in the real code. The point of defining them in a separate interface that doesn’t extend the React props, and then combining the types in the component definition, is to make it easy to document, browse, and create objects containing the component-scope props, without them getting lost in the noise of 260+ native HTML props.
Actual behavior:
Instead, the Omit definition is replaced by the compile-time equivalent as a Pick statement. This defeats the self-documenting semantic that is intended by using Omit. It is very confusing to library consumers – “why is there such a gigantic Pick type with 200+ entries???” And it actually changes the intended semantic, as the meaning of the Omit-based type is intended to be evaluated by the typescript compiler when the user of the library compiles, not when the library itself is compiled. e.g. if a new version of @types/react includes a new event type, I should not have to recompile/redistribute my library to support it.
I don’t-know-I-don’t-know WAY MORE about compilers than I know, so I am certainly open to education on this, but both of the issues raised here do seem like (subtle) bugs to me.
About this issue
- Original URL
- State: closed
- Created 5 years ago
- Reactions: 7
- Comments: 18 (11 by maintainers)
Or we can just have some way of telling the compiler to not expand a type alias, https://github.com/microsoft/TypeScript/issues/35654
@ExE-Boss I edit sniped you with a corrected declaration that does work.
Just write your own
Omittype,Playground
It’s a pretty difficult problem, in the general case.
Usually, the emit is not considered part of the behaviour of these type alises/operators. Because it shouldn’t matter what the emit is, as long as it the emitted types follow the expected assignability rules, have the right properties, etc. (Occasionally, there are bugs and the emitted types have a different behaviour from the original types. Whoops!)
However, when you add humans into the mix, suddenly, the emit kind of matters (readability).
Because sometimes, a 2 line emit is more readable than a 100+ line emit. Other times, the 100+ line emit is more readable than the 2 line emit (it’s possible, trust me).
You can have two types that have the exact same behaviour, except for having different emit. And you cannot say, with certainty, which type is better. (For example,
Omit<>vsOmit2<>)The human using the types has to decide if type
Aor typeBis better for a particular use case. All this trouble, because these types have different emit, despite having the exact same behaviour.I actually unit test the
.d.tsoutput of my library now, because readability for the downstream consumer is kind of important to me =xFWIW I wanted to point out that I hit this issue and the proposed workaround of
Omit2does not work until typescript 3.9.It looks to me like index signature types over a type using
Omitalways produce an exhaustivePickcomplement which breaks a composed type in a private package I wrote where the input toOmitcomes from a peer dependency, and therefore the generated key union type can be wrong. The outputtedPickis generated from my installed devDependencies, but should be resolved on the consumer’s side with their installed peer dependencies. The proposed workaround not working means my package can only support typescript 3.9,but I might be able to move the generic type order to work around it.Here is a minimal viable reproduction, with commits in the history confirming it works in 3.9 but not 3.8.
EDIT: looks like inverting the composition order does not help in 3.8
Somewhat related to https://github.com/microsoft/TypeScript/issues/34556
If there was a way to control TS to alias, or not alias a type, one could just say,
Then, output will always use
Omit<T, K>.Or,
Then, the output will always use
Pick<T, Exclude<keyof T, K>>Right now, whether a type alias gets “expanded” or not is a bit unintuitive.
Often times, I have to use the
Identity<>trick. Otherwise, the output is 100s of lines long, when the “expanded” type is only 5 lines long or something.