Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type not narrowed by checking the value of a template literal type #53887

Closed
marekdedic opened this issue Apr 18, 2023 · 4 comments
Closed

Type not narrowed by checking the value of a template literal type #53887

marekdedic opened this issue Apr 18, 2023 · 4 comments

Comments

@marekdedic
Copy link
Contributor

Bug Report

πŸ”Ž Search Terms

template literal, string interpolation, type narrowing

πŸ•— Version & Regression Information

  • I was unable to test this on prior versions prior to 4.1 because template literal types were only added then

⏯ Playground Link

I have a hard time describing this issue, the playground link will explain it better I think:

Playground Link

I would expect fn2 to work in the same way that fn1 does

πŸ’» Code

type A = {
	type: "atype";
	acontent: string;
}

type B = {
	type: "btype";
	bcontent: string
}

function fn1(val: A|B) {
	if(val.type === "atype") {
		console.log(val.acontent);
	}
	if(val.type === "btype") {
		console.log(val.bcontent);
	}
}

type Modified<T extends A|B> =
	Omit<T, "type"> & {
		type: `prefix-${T["type"]}`;
	};

function fn2(val: Modified<A|B>) {
	if(val.type === "prefix-atype") {
		console.log(val.acontent);
	}
	if(val.type === "prefix-btype") {
		console.log(val.bcontent);
	}
}

πŸ™ Actual behavior

Errors in the console.logs in fn2

πŸ™‚ Expected behavior

No errors, same as in fn1.

@MartinJohns
Copy link
Contributor

MartinJohns commented Apr 18, 2023

Your type Modified<> does not work at all as you think it does.

First the part Omit<T, "type">. Omit<> does not work distributive, so when using Omit<A | B, "type"> you don't end up with { acontent: string } | { bcontent: string }, but instead with {}. Taking all common properties from the first type A | B (which is only type), then removing the type property.

Then the part { type: ``prefix-${["type"]`` }. This again does not work distributive, so you end up with { type: "prefix-atype" | "prefix-btype" }.

That means your final type for Modified<A | B> is actually just {} & { type: "prefix-atype" | "prefix-btype" }, or simplified just { type: "prefix-atype" | "prefix-btype" }. Not a union type you can narrow, not a type that provides information for acontent or bcontent.


This is how I would write it:

type Modified<T extends A | B> = { [P in keyof T]: P extends "type" ? `prefix-${T[P]}` : T[P] };

Not sure if there's a more elegant way to write this type, but this works as you expect.

You could also write it like this. The important part is the conditional type, resulting in distributive behaviour:

type Modified<T extends A | B> = T extends any ? Omit<T, "type"> & { type: `prefix-${T["type"]}` } : never

@marekdedic
Copy link
Contributor Author

marekdedic commented Apr 18, 2023

Your type Modified<> does not work at all as you think it does.

That's an excellent observation. The fact that Omit isn't distributive is somewhat counterintuitive to me at this point, but that's another thing.

Thanks for your suggestions, however my example is a bit more complicated in a way that mixes with your suggested solutions n. 2 - I was originally doing it like this:

import type { Node } from "postcss";

type Modified<PostCSSNode extends Node> =
  Omit<PostCSSNode, "type"> & {
    type: `SvelteStyle-${PostCSSNode["type"]}`;
  };

However, this works:

type Modified<PostCSSNode extends Node> =
  PostCSSNode extends any
    ? Omit<PostCSSNode, "type"> & {
          type: `SvelteStyle-${PostCSSNode["type"]}`;
        }
    : never;

I'm leaving this here for posterity, however, I have absolutely no idea why this changes anything, since I presumed the original generic would do basically the same thing...

@MartinJohns
Copy link
Contributor

The fact that Omit isn't distributive is somewhat counterintuitive to me at this point, but that's another thing.

It's been implemented that way, and changing it now would be a huge breaking change. It's unlikely to be changed: #53696 (comment)

Omit isn't distributive and the odds we can change it to be distributive seem pretty low. See #53169


I have absolutely no idea why this changes anything, since I presumed the original generic would do basically the same thing...

It changes because you're using a conditional type, causing your type to be evaluated distributive. Distributiveness matters when you pass in a union type. A quick example type type Foo<T> = Omit<T, "demo"> with T being A | B | C:

  • Not distributive your union type is passed "as is":
    Omit<A | B | C, "demo">
  • Distributive your union type is "split", and the "right side" is applied for each type in your union:
    Omit<A, "demo"> | Omit<B, "demo"> | Omit<C, "demo">.

See also the documentation: Distributive Conditional Types

This matters a lot with Omit<>, because Omit<> is using keyof on the first type argument to get the property names, but keyof on a union type will only return the properties common to all types of the union.

Hope my explanation is somewhat understandable.

@marekdedic
Copy link
Contributor Author

Uff, thanks for your patience and great explanations. I see, yeah, changing something like Omit would be a huge breaking change.

Every time I think I understand TS reasonably well, a thing like distributive conditional types comes along and convinces me otherwise :D

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

No branches or pull requests

2 participants