Skip to content

Coerce @AppStorage values across primitive types instead of crashing - #491

Open
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/appstorage-type-coercion
Open

Coerce @AppStorage values across primitive types instead of crashing#491
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/appstorage-type-coercion

Conversation

@vincentborko

Copy link
Copy Markdown
Contributor

Motivation

On iOS, @AppStorage coerces between primitive types: you can store a value as a Double and read it back as a String, or vice versa. On Android the same code crashes (#317) — e.g. store "1.0" under a key, then read it through a Double-typed @AppStorage:

java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Number

Root cause

AppStorage<Value>.trackState() read the stored object with:

value = object as? Value

Under Kotlin's type erasure Value is unknown at runtime, so object as? Value is a no-op: it neither coerces the value nor rejects a mismatched type. A String stored value flowed unchanged into a Double-typed property and crashed a later consumer.

The bridged AppStorageSupport doesn't have this problem because it captures a per-type get closure at init (e.g. { $1 as? Double ?? $0.double(forKey: key) }) using UserDefaults' coercing typed accessors. The generic AppStorage<Value> has only one generic initializer, so it has no such per-type dispatch point — but its _wrappedValue still holds a concrete runtime value.

Fix

Dispatch on the concrete runtime type of _wrappedValue and read through the matching typed UserDefaults accessor, mirroring AppStorageSupport's coercion. Both the initial read and the change-listener read now go through a single storedValue(from:store:) helper. Behavior is otherwise unchanged: an absent key still returns nil (seeding the default), and a deserializer, when present, still takes priority.

Testing

New AppStorageTests (runs under Robolectric via the transpiled test path):

  • testAppStorageCoercesStringToDouble — stores "1.0", reads a Double, expects 1.0.
  • testAppStorageCoercesIntToDouble — stores 3, reads a Double, expects 3.0.

Verified the tests are a real regression guard: against the pre-fix object as? Value both fail under Robolectric (AssertionError: 1.0 != 1.0 — String vs Double — and 3 != 3.0), and pass with the fix. Full transpiled SkipUI suite green (94 tests, 0 failures).

On iOS `@AppStorage` coerces between primitive types via UserDefaults'
typed accessors: a value stored as a String can be read back as a Double,
etc. On Android `AppStorage<Value>.trackState()` read the stored object
with `object as? Value`, which under Kotlin's type erasure is a no-op — it
neither coerces nor rejects a mismatched type. A value stored under one
type therefore flowed unchanged into a property of another, crashing a
later consumer with a ClassCastException (issue skiptools#317).

Dispatch on the concrete runtime type of the current wrapped value and use
the matching typed UserDefaults accessor, mirroring the coercion the
bridged AppStorageSupport already performs. Factored both the initial read
and the change-listener read through a single `storedValue(from:store:)`
helper.

Fixes skiptools#317.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant