Skip to content

fix(types): accept an explicit undefined on every added prop, styled() option and mapping entry - #418

Draft
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:rncss/types-optional-undefined
Draft

YevheniiKotyrlo wants to merge 2 commits 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.

A consumer cannot fix it locally, because declaration merging can add props but not widen an existing one.

A mapping's entries have a gap of their own. StyledConfigurationObject resolves a string target with ResolveDotPath<T, ComponentProps<C>>, the reverse of its declaration, so a target's own mapping type is never and only the target: false branch's Record checks a mapping. At runtime mappingToConfig keeps an entry left undefined, 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" />;

Solution

  • Every optional member of types.d.ts and runtime.types.ts takes | undefined, as React Native writes its own, except InlineStyleRecord's s?: never, which marks a key that must stay absent.
  • 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.
  • ResolveDotPath takes its arguments in declared order, so a mapping is also typed by its target's keys, each of which may be left undefined, and mappingToConfig drops such an entry.

Tests

  • src/__tests__/types/optional-members.test.ts walks both files with the type checker, the flag on, and fails on any optional member that rejects undefined: a property, a class property, a method, a tuple element, a mapped type over keyof T or over a K extends keyof T, and a library type such as Partial that adds ?. Its fixture holds each of those forms, beside an alias, void and an indexed access that include undefined, which pass. Against main it reports 21 members, 12 in types.d.ts and 9 in runtime.types.ts; here it reports none.
  • src/__tests__/native/styled.test.ios.tsx: a mapping entry set to undefined maps nothing. On main the case fails tsc, and its render throws.

With the flag on, <StyledView className={maybeString} /> for styled(View), passThrough: maybeFlag, nativeStyleMapping: maybeMapping and a mapping entry left undefined each fail on main and compile here.

Verification

Every ci.yml job passes on this head merged into main with #477's harness fix: lint, typecheck, the tests (1344 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.

With the mapping's own branch live, tsc --noEmit over this repository instantiates 8.0% more types (1,058,545 against 979,787), and its check time moves from 2.85 s to 3.07 s, medians of five runs. The runs, and the snippet above on main and on this branch, are in this comment.

Known limits

  • The test walks types.d.ts and runtime.types.ts only. The compiler's and Metro's options, the jest helper's debug and the Babel plugin's target, runtime and commonjs keep bare optional members, and the package's own code has 17 errors under the flag, in eight files, as on main.

Merge order

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The same image. A type declaration has no compiled twin; the evidence is that this story COMPILES, which it cannot against stock types.

FIXED — Each explicit-undefined slot is empty exactly like the omitted-prop slot beside it, and the resolved slot is filled.

before — 3.1.0-rc.0 after — with this PR

Apart from the typeface, the two frames are the same image: the change is to the declarations.

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

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 over this repository, five runs a side, alternating, with ResolveDotPath's arguments in order and swapped back as on main; the swapped side drops the new case's undefined entry, which only the fixed types accept.

types instantiations memory check time
arguments swapped, as on main 169,351 979,787 724 MB (679–748) 2.85 s (2.69–4.41)
arguments in order, this PR 174,113 1,058,545 770 MB (713–776) 3.07 s (2.82–3.16)

The counts are the same on every run; memory and time are medians with their range. Each styled() mapping now also resolves its target's own keys, which is what the added instantiations are.

Windows 11 Pro 10.0.26200, 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
…s package declares

React Native declares its optional props as `?: T | undefined`, and the props
this package adds were bare `?: string`. Under exactOptionalPropertyTypes the
two differ, so `className={condition ? "p-4" : undefined}` failed to compile,
and declaration merging cannot widen an existing prop from the consumer side.

Every optional member of types.d.ts and runtime.types.ts now takes
`| undefined`, except InlineStyleRecord's `s?: never`, which marks a key that
must stay absent. The props a mapping adds come from one StyledSourceProps
type, which the unmapped overload uses with DefaultStyledConfiguration, the
default mapping both platforms pass.

A test walks both files with the type checker under the flag and fails on any
optional member that rejects undefined, so a member added later is covered too.
…an entry left undefined

StyledConfigurationObject called ResolveDotPath<T, ComponentProps<C>>, the
reverse of its declaration ResolveDotPath<T, Path>, so a string target's
mapping type was never and only the target:false branch's Record checked a
mapping. With the arguments in order, an entry may be left undefined, and
mappingToConfig drops it, where nativeStyleMapping called `path.split(".")` on
it and threw.
@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

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