JustinM
JustinM

Reputation: 1003

How to update deeply nested object (type narrowing not working as expected, keyof compile errors)

I'm writing a function to update a deeply nested object. The goal is to be able to type something like copyWith(person, 'address' ,'street', 'zip', 98443) to generate a copy where a deeply nested property is different than the original. I think this is the lenses feature in some functional libraries. It kind of works. See the test below. But if I add a type definition for the input object - which I like doing because it helps me ensure I'm creating a valid source object - then it no longer compiles and I don't know why. At the point in the code where I'm trying to call copyWith, shouldn't the type of the variable passed as input to copyWith be narrowed to the signedIn path with custom settings? Is there some way of getting this to work without having to explicity create and use a type for that narrowed path?

it('can clone object with a deep change', () => {
  const copyWith = <
    T,
    A extends keyof T,
    B extends keyof T[A],
    C extends keyof T[A][B],
    V
  >(
    i: T,
    a: A,
    b: B,
    c: C,
    value: V
  ) => ({ ...i, [a]: { ...i[a], [b]: { ...i[a][b], [c]: value } } });

  type Privacy = { mode: 'public' } | { mode: 'private' };

  type Settings =
    | { settings: 'default' }
    | { settings: 'custom'; privacy: Privacy };

  type User =
    | { state: 'unauthorized' }
    | { state: 'signedIn'; settings: Settings };

  // =========================================================
  // If add :User type definition, the test no longer compiles
  // =========================================================
  const user = {
    state: 'signedIn',
    settings: { settings: 'custom', privacy: { mode: 'private' } },
  };

  if (user.state === 'signedIn' && user.settings.settings === 'custom') {
    const copied = copyWith(user, 'settings', 'privacy', 'mode', 'public');
    assert.equal(copied.settings.privacy.mode, 'public');
  } else {
    throw new Error('This path should not have been taken.');
  }
});

Upvotes: 2

Views: 359

Answers (1)

Buszmen
Buszmen

Reputation: 2426

You are correct thinking that discriminated union should work here. Unfortunately it doesn't work for nested objects (hopefully yet). You have to use top union to determine the whole type structure:

const copyWith = <
    T,
    A extends keyof T,
    B extends keyof T[A],
    C extends keyof T[A][B],
    V
  >(
    i: T,
    a: A,
    b: B,
    c: C,
    value: V
  ) => ({ ...i, [a]: { ...i[a], [b]: { ...i[a][b], [c]: value } } });

  type Privacy = { mode: 'public' | 'private' };

  type User =
    | { state: 'unauthorized' }
    | { 
      state: 'signedIn'; 
      settings: { settings: 'default' } 
    }
    | { 
      state: 'signedIn'; 
      settings: { settings: 'custom'; privacy: Privacy } 
    }

  // =========================================================
  // If add :User type definition, the test no longer compiles
  // =========================================================
  const user: User = {
    state: 'signedIn',
    settings: { settings: 'custom', privacy: { mode: 'private' } },
  };

  if (user.state === 'signedIn' && user.settings.settings === 'custom') {
    const copied = copyWith(user, 'settings', 'privacy', 'mode', 'public');
    assert.equal(copied.settings.privacy.mode, 'public');
  } else {
    throw new Error('This path should not have been taken.');
  }

TS Playground

Also...Privacy type can be a little simpler ;)

Upvotes: 1

Related Questions