Skip to content

fix(types): accept an explicit undefined wherever a consumer writes an optional member - #418

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:rncss/types-optional-undefined
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:rncss/types-optional-undefined

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

React Native declares its own optional props as ?: T | undefined, while the props this package adds are bare ?: string. Under exactOptionalPropertyTypes the two differ, and the difference falls on the most ordinary thing a consumer writes:

<View className={condition ? "p-4" : undefined} />
error TS2769: No overload matches this call.
  Overload 1 of 2, '(props: ViewProps): View', gave the following error.
    Type '{ className: string | undefined; }' is not assignable to type 'Readonly<ViewProps>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.

styled() has the same bare members: the className its unmapped overload adds, passThrough, and a mapping's nativeStyleMapping; so do the options a consumer writes for the compiler, Metro, the jest helper and useNativeCss. A consumer's own declaration that widens such a prop takes effect only when it comes before this package's in the program.

A mapping entry left undefined is also a runtime error: mappingToConfig keeps it, and nativeStyleMapping calls path.split(".") on it:

const StyledView = styled(View, {
  className: {
    target: "style",
    nativeStyleMapping: { color: "accessibilityLabel", backgroundColor: undefined },
  },
});

// .subject { color: blue; background-color: red; }
// The render throws "Cannot read properties of undefined (reading 'split')",
// and the render React retries has no backgroundColor.
<StyledView className="subject" />;

A null or false entry, which the types reject but JavaScript can pass, fails the same way, and a null mapping or a string nativeStyleMapping fails with a TypeError or maps each character.

Solution

  • Every optional member of types.d.ts and runtime.types.ts, of the compiler's, Metro's, the jest helper's and useNativeCss's options, and of the reactivity Effect an observable's get() takes, takes | undefined, as React Native writes its own, except InlineStyleRecord's s?: never, which marks a key that must stay absent; the compiler's feature flags take it too. registerCSS and compileWithAutoDebug take one option type, which leaves out logger, which the helper always sets itself, and lets registerCSS pass on the debug: "verbose" it already honours.
  • The props a styled() mapping adds come from one StyledSourceProps type, which StyledProps, StyledReactElement and StyledComponent use, and which the unmapped overload uses with DefaultStyledConfiguration, the default mapping src/native/api.tsx and src/web/api.tsx both pass. They are writable, as the component's own props are, where main made the mapped ones read-only.
  • A mapping's keys are the style keys the compiler collects: React Native's, which an editor completes, or the camel-cased name of a -rn- declaration, so any string is a key. The type no longer resolves the mapping's target, and an entry may be left undefined, which mappingToConfig drops. On native, any other value it cannot map, an entry, a nativeStyleMapping or the mapping itself, fails with an Invalid mapping error that names it when styled() or useCssElement reads the mapping. inlineVariables.exclude takes a readonly list, which compile() only reads.

Tests

  • src/__tests__/types/optional-members.test.ts checks what a consumer writes with the type checker, the flag on: the props types.d.ts adds and runtime.types.ts, read whole, and what each entry point of package.json's exports takes: a function's parameters and type constraints, what a component or function it returns takes, and an instance's methods. It leaves what a consumer only reads, such as a callback's arguments, another package's mapped types, native-internal, whose caller is the generated injection code, and the compiled stylesheet compile() writes, which a consumer only passes back. It fails on any optional member that rejects undefined, naming each place once: a member under the type that declares it, and one a mapped type such as Partial makes optional, or gives a type without undefined, where the mapped type is applied. It reads every member a consumer reaches from those types, through call and construct signatures (their type parameters' constraints, parameters and return types), a class's constructor, index signatures, base types, unions, intersections, tuples and a generic's arguments, each type once, a recursive alias included; a generic declaration is read as written. Its fixtures hold a case for each reach and each form, beside the ones that pass. On main it names 41 places an optional member rejects undefined; here none.
  • src/__tests__/native/_styled-types.tsx: a mapping takes any style key, a -rn- declaration's among them, and an entry left undefined, rejects a value naming no prop, and the class props stay writable; on main tsc fails the undefined entry and the writable mapped prop.
  • src/__tests__/types/styled.test.ts: the editor completes React Native's style keys under target: false and under a string target; both fail on main.
  • src/__tests__/types/compiler-options.test.ts: inlineVariables.exclude takes a list declared as const; main fails it with TS4104.
  • src/__tests__/native/styled.test.ios.tsx: a mapping entry set to undefined maps nothing, and any other value it cannot map, an entry, a nativeStyleMapping or the mapping itself, fails at styled() with an error that names it. On main the first fails tsc and its render throws, and the others reach the render or throw a TypeError.
  • DEVELOPMENT.md and the repo's add-test and triage skills list the test domains, types/ and the Node tooling tests among them, and add-test says how each runs: yarn typecheck for a case tsc alone decides, node --test after a build, and maestro test for a flow.

Verification

Every ci.yml job passes on this head with #477's harness fix merged in: lint, typecheck, the tests (1392 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

A styled() mapping with a nativeStyleMapping now costs the type checker about 8,100 instantiations against 35,100 on main, where one without costs about 8,000 on both, and tsc --noEmit over this repository makes 954,279 type instantiations against 979,505. The runs, the snippet above and the className={maybe} check on main and on this branch are in this comment.

Known limits

  • In an Expo app, expo/types declares className on ViewProps, TextProps and other React Native props without | undefined, and expo-env.d.ts loads it before this package's declarations, so there className={maybe} still fails until Expo's declarations widen too.
  • A misspelled mapping key compiles, as on main, since a -rn- declaration can add any key to the style.
  • Under the flag, spreading React Native props into a component that declares a bare className?: string now fails, as spreading React Native's own | undefined props into one already does.
  • styled() over Reanimated's animated components still exceeds the type checker's depth (TS2589, TS2590), as on main, at about 15% fewer instantiations.
  • The package's own code has 11 errors under the flag, in six files; this PR's widening of CompilerOptions.logger and Config.nativeStyleMapping clears the 6 others main has.

Merge order

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

className={maybe} under the flag — main against this PR

import { Text, View } from "react-native";
import { styled } from "react-native-css";

declare const condition: boolean;
const StyledView = styled(View);

export const elements = [
  <View className={condition ? "p-4" : undefined} />,
  <Text className={condition ? "p-4" : undefined} />,
  <StyledView className={condition ? "p-4" : undefined} />,
];

tsc with strict and exactOptionalPropertyTypes, over this file and the package's types.d.ts:

errors in the file
main (2421ea77) three TS2769: No overload matches this call, one per element
this PR, merged into main none

A mapping entry left undefined — main against this PR

The snippet from the body, rendered through the repo's jest with a window.dispatchEvent stub, so React's report of the error it recovered from reaches console.error:

errors accessibilityLabel style
main (eda2b286) There was an error during concurrent rendering but React was able to recover by instead synchronously rendering the entire root., caused by Cannot read properties of undefined (reading 'split') #00f {}
this PR none #00f { backgroundColor: "#f00" }

Type-check cost — main against this PR

tsc --noEmit --extendedDiagnostics on clean checkouts of main and of this head merged into it, each started at high priority on the same sixteen logical processors; the counts are the same on every run.

main (2421ea77) this PR
this repository: types 167,998 171,599
this repository: instantiations 979,505 954,279
a file importing styled: instantiations 480,167 479,980
the same file with 13 styled() mappings over React Native's components: instantiations 936,529 585,414
each mapping, on average 35,105 8,110
three styled() mappings over Reanimated's Animated.View, Animated.Text and Animated.ScrollView: instantiations 23,011,189 19,617,724
the same file: errors 2 × TS2589, 1 × TS2590 the same errors

Check times varied more between runs of one side than between the sides, so the table gives only the counts.

The mapping type no longer resolves its target: its keys are React Native's style keys, which an editor completes, beside any string.

Windows 11 Pro 10.0.26300, AMD Ryzen 9 9950X3D, Node 26.8.1, TypeScript 6.0.3.

@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the rncss/types-optional-undefined branch from 6c6da94 to d10ad90 Compare October 11, 2026 12:35
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 20, 2026 09:53
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every prop this package adds fix(types): accept an explicit undefined on every prop and styled() option this package adds Oct 20, 2026
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the rncss/types-optional-undefined branch from 8c8919f to 49ff1c9 Compare October 20, 2026 15:02
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every prop and styled() option this package adds fix(types): accept an explicit undefined on every prop, styled() option and mapping entry this package adds Oct 20, 2026
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every prop, styled() option and mapping entry this package adds fix(types): accept an explicit undefined on every added prop, styled() option and mapping entry Oct 20, 2026
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(types): accept an explicit undefined on every added prop, styled() option and mapping entry fix(types): accept an explicit undefined wherever a consumer writes an optional member Oct 20, 2026
…n option

Under exactOptionalPropertyTypes a bare `?: T` rejects the explicit
undefined React Native's own optional props accept, so
`className={condition ? "p-4" : undefined}` did not compile. Every
optional member a consumer writes now takes `| undefined`: the props
types.d.ts adds, runtime.types.ts, the options of the compiler, Metro,
the jest helper and useNativeCss, and what the exports' parameters
reach. `InlineStyleRecord`'s `s?: never` stays, marking a key that must
stay absent.

The props a styled() mapping adds come from one StyledSourceProps type.
A mapping's keys are the style keys the compiler collects, so its type
no longer resolves the target. styled() drops an entry left undefined
and fails with its Invalid mapping error for any value it cannot map.

optional-members.test.ts walks, with the type checker and the flag on,
what a consumer writes to each entry point package.json exports, and
fails on any optional member that rejects undefined.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the rncss/types-optional-undefined branch from e283081 to 157684a Compare October 22, 2026 11:52
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 22, 2026 12:06

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant