Typescript: React: enforce correct usage of refs

Created on 6 Jun 2019  ·  2Comments  ·  Source: microsoft/TypeScript

Potentially a use case for #12936

if you have a problem and you think exact types are the right solution, please describe the original problem here

https://github.com/microsoft/TypeScript/issues/12936#issuecomment-284590083

Search Terms

react refs exact type

Suggestion

In React, it's fairly common to use refs. This involves creating an object of a specific type, and then passing that value as a prop to a component, which will mutate/reassign its current property.

Currently it seem there is no way to use refs in a type safe way. They can be accidentally passed to the wrong component, and there will be no type error.

https://stackoverflow.com/questions/56378639/typescript-react-enforce-correct-ref-props

import * as React from 'react';

class Modal extends React.Component<{}> {
    close = () => {};
}

declare const modal: Modal;

modal.close();

const modalRef = React.createRef<Modal>();

// Let's try giving this ref to the correct component…
// No error as expected :-)
<Modal ref={modalRef} />;

class SomeOtherComponent extends React.Component<{}> {}

// Let's try giving this ref to the wrong component…
// Expected type error but got none! :-(
<SomeOtherComponent ref={modalRef} />;

IIUC, this is because ref={modalRef} is equivalent to:

declare let ref: { current: {} };
declare let modalRef: { current: { close: () => void } };
ref = modalRef;

… which will correctly not error, because modalRef is a subtype of the target type ref.

However, when we're passing a ref to a component, we want to make sure the ref argument value (modalRef) is a _supertype_, or exact match, of the parameter type (ref).

This is because the component is responsible for mutating the ref's current property—if we pass in a ref of the wrong type, it will be mutated to something else. Later, when we try to use the ref's current value, the value will not match the type:

// Now when we try to use this ref, TypeScript tells us it's safe to do so.
// But it's not, because the ref has been incorrectly assigned to another component!
if (modalRef.current !== null) {
    modalRef.current.close(); // runtime error!
}

I am looking for a way to catch these mistakes at compile time, with TypeScript.

Note if the target component is a subtype of React.Component, we will get errors as desired:

class SomeOtherComponent extends React.Component<{}> {
    foo = () => {}
}

// Let's try giving this ref to the wrong component…
// We got an error :-)
<SomeOtherComponent ref={modalRef} />;

Checklist

My suggestion meets these guidelines:

  • [ ] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [x] This wouldn't change the runtime behavior of existing JavaScript code
  • [x] This could be implemented without emitting different JS based on the types of the expressions
  • [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • [x] This feature would agree with the rest of TypeScript's Design Goals.
Needs Proposal Suggestion

Most helpful comment

This is a fundamental problem with a covariant-by-default type system - the implicit assumption is that writes through supertype aliases are rare, which is true except for the cases where it isn't. You don't really even need generics:

type Base = { a: number };
type Derived = { a: number, b: string };

function mutate(x: { derived: Base }) {
    x.derived = { a: 0 };
}

const holder: { derived: Derived } = { derived: { a: 0, b: "" } };
mutate(holder);
// Crash, sad
holder.derived.b.toLowerCase();

An exact type on mutate isn't even particularly what you want, since probably { derived: { a: 0 | 1 } } is an OK argument even though it's technically a subtype (thus disallowed?!) of the specified parameter type.

The primitive you actually need is something like contravariant T that takes a T and flips around its relations, so you could write

function mutate(x: contravariant { derived: Base }) {
    x.derived = { a: 0 };
}

// Error, can't assign "Base" to "Derived"
mutate(holder);

All 2 comments

Seems like more of a variance annotations thing for generics.

This is a fundamental problem with a covariant-by-default type system - the implicit assumption is that writes through supertype aliases are rare, which is true except for the cases where it isn't. You don't really even need generics:

type Base = { a: number };
type Derived = { a: number, b: string };

function mutate(x: { derived: Base }) {
    x.derived = { a: 0 };
}

const holder: { derived: Derived } = { derived: { a: 0, b: "" } };
mutate(holder);
// Crash, sad
holder.derived.b.toLowerCase();

An exact type on mutate isn't even particularly what you want, since probably { derived: { a: 0 | 1 } } is an OK argument even though it's technically a subtype (thus disallowed?!) of the specified parameter type.

The primitive you actually need is something like contravariant T that takes a T and flips around its relations, so you could write

function mutate(x: contravariant { derived: Base }) {
    x.derived = { a: 0 };
}

// Error, can't assign "Base" to "Derived"
mutate(holder);
Was this page helpful?
0 / 5 - 0 ratings

Related issues

Antony-Jones picture Antony-Jones  ·  3Comments

weswigham picture weswigham  ·  3Comments

siddjain picture siddjain  ·  3Comments

seanzer picture seanzer  ·  3Comments

CyrusNajmabadi picture CyrusNajmabadi  ·  3Comments