Skip to content

Conversation

@atomanyih
Copy link

@atomanyih atomanyih commented Aug 26, 2025

Reproduction:

export const createWritableAtom = (): WritableAtom<
  null,
  [],
  number | null
> =>
  atom(
    () => null,
    (get, set, someArg: string) => {
      return null;
    },
  );

Error:

1. Type 'WritableAtom<null, [someArg: string], null>' is not assignable to type 'WritableAtom<null, [], number | null>'.
2.   Types of property 'read' are incompatible.
3.     Type 'Read<null, SetAtom<[someArg: string], null>>' is not assignable to type 'Read<null, SetAtom<[], number | null>>'.
4.      Type 'SetAtom<[], number | null>' is not assignable to type 'SetAtom<[someArg: string], null>'.
5.        Type 'number | null' is not assignable to type 'null'.
6.          Type 'number' is not assignable to type 'null'. ts(2322)

This code should be valid: a function that returns null is assignable to a function that returns number | null, and I would expect writable atom to work the same way.

Explanation

From some digging, it seems like the issue is stemming from Result being propagated down to the SetSelf parameter of Read in atom.ts

type Read<Value, SetSelf = never> = (get: Getter, options: {
    readonly signal: AbortSignal;
    readonly setSelf: SetSelf;
}) => Value;

I think what is happening is that, because SetSelf is an argument Read, typescript infers that it should be contravariant. That's why the assignability gets inverted from lines 3 to 4 of the error.

Fix

I haven't fully examined the internal usage of setSelf, so forgive me if this is totally off the mark. But it looks like the return value is not used in any of the call sites. So I think we could modify the typing of WritableAtom to remove Result from read.

export interface WritableAtom<Value, Args extends unknown[], Result>
  extends Atom<Value> {
  read: Read<Value, SetAtom<Args, void>>
  write: Write<Args, Result>
  onMount?: OnMount<Args, Result>
}

This type change is saying, in effect, that we don't care about the return value of SetAtom in the Read function.
I've also added a type test to ensure the assignability of WritableAtom remains correct.

Let me know if I've gotten anything wrong!

Check List

  • pnpm run fix for formatting and linting code and docs

@vercel
Copy link

vercel bot commented Aug 26, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
jotai Ready Ready Preview Comment Oct 16, 2025 1:36pm

@codesandbox-ci
Copy link

codesandbox-ci bot commented Aug 26, 2025

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for digging into it. Yeah, SetSelf is tricky, and I'm not super happy with it.
I think using unknown is more appropriate. (If not, never? But if unknown works, it feels safer.)

@atomanyih
Copy link
Author

@dai-shi Yeah, I flip flopped on this a couple times. My thinking was:

() => void means a function whose return value cannot/will not be used
whereas () => unknown means a function whose return value is not known, but can be used if it is cast or narrowed to a know type.
I think () => never would mean a function that never returns, which wouldn't be correct.

But I think either void or unknown seems good and I'm happy to change it to unknown

@dai-shi
Copy link
Member

dai-shi commented Aug 27, 2025

@atomanyih Sorry for the delay. On second thought, I think the original idea is to support this case.

it('shoud type setSelf return value', () => {
  function Component() {
    const numberAtom = atom(0)
    const writableAtom: WritableAtom<string, [], number> = atom(
      (_get, { setSelf }) => {
        const doubleNum = setSelf()
        return String(doubleNum + 1)
      },
      (get, _set) => {
        return get(numberAtom) * 2
      },
    )

    expectType<WritableAtom<string, [], number>>(writableAtom)
  }
  expect(Component).toBeDefined()
})

Can you reconsider the solution to support both? We may end up dropping either if we don't find any solutions.


it('WritableAtom Result type should be covariant', () => {
function Component() {
const writableAtom = atom(null, () => null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't represent your original reproduction. Can you do something similar to your repro in the PR description?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which part of the original reproduction do you mean? () => null or [someArg: number]?

The someArg was actually a mistake. That wouldn't be assignable anyway (the arguments should be contravariant). I fixed the example in my local reproduction and updated the PR description

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean in this case, we could do:

Suggested change
const writableAtom = atom(null, () => null)
const writableAtom = atom(null, () => null as null | number)

I think we need the actual case in the test:

export const createWritableAtom = (): WritableAtom<
  null,
  [],
  number | null
> =>
  atom(
    () => null,
    (get, set, someArg: string) => {
      return null;
    },
  );

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

☝️ Does it make sense?

@atomanyih
Copy link
Author

@atomanyih Sorry for the delay. On second thought, I think the original idea is to support this case.

it('shoud type setSelf return value', () => {
  function Component() {
    const numberAtom = atom(0)
    const writableAtom: WritableAtom<string, [], number> = atom(
      (_get, { setSelf }) => {
        const doubleNum = setSelf()
        return String(doubleNum + 1)
      },
      (get, _set) => {
        return get(numberAtom) * 2
      },
    )

    expectType<WritableAtom<string, [], number>>(writableAtom)
  }
  expect(Component).toBeDefined()
})

Can you reconsider the solution to support both? We may end up dropping either if we don't find any solutions.

Can you explain more about this use case?
It looks like you have a derived atom that, when read, calls the its own set method?

@dai-shi
Copy link
Member

dai-shi commented Aug 28, 2025

That is a very exceptional use case, we hack reading other atoms in our "read" function. It's probably used in some of third-party libraries. So, if we change the type from Return to unknown, those library will be broken in types. Which is kind of acceptable, if this PR case is more important.

@dai-shi
Copy link
Member

dai-shi commented Aug 28, 2025

btw, I want to drop setSelf in the future version... #2889

@pkg-pr-new
Copy link

pkg-pr-new bot commented Oct 16, 2025

More templates

npm i https://pkg.pr.new/jotai@3135

commit: cffb1ae

@github-actions
Copy link

LiveCodes Preview in LiveCodes

Latest commit: cffb1ae
Last updated: Oct 16, 2025 1:36pm (UTC)

Playground Link
React demo https://livecodes.io?x=id/8296NM5XC

See documentations for usage instructions.

@dai-shi
Copy link
Member

dai-shi commented Oct 18, 2025

/ecosystem-ci run

@github-actions
Copy link

Ecosystem CI Output

---- Jotai Ecosystem CI Results ----
{
  "bunshi": "PASS",
  "jotai-devtools": "PASS",
  "jotai-effect": "PASS",
  "jotai-scope": "PASS",
  "waku-jotai": "PASS"
}

Copy link
Member

@dai-shi dai-shi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm considering to merge this for the next patch.
@atomanyih Please check comments.


it('WritableAtom Result type should be covariant', () => {
function Component() {
const writableAtom = atom(null, () => null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

☝️ Does it make sense?

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.

2 participants