Reputation: 1003
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
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.');
}
Also...Privacy type can be a little simpler ;)
Upvotes: 1