Wanna see something cool? Check out Angular Spotify 🎧

Google TypeScript Style Guide

Just realised my last blog post was in July 2025. Since then, I have attended Google I/O Connect 2025 Shanghai in August, spoke at Øredev 2025 in Sweden in November, and then JSConfJP 2025 just a few days after coming back from Sweden. It has been a busy second half of the year.

In between all the travel and talks, I finally had some quiet time to sit down and read the Google TypeScript Style Guide and picked up quite a few solid lessons along the way. Some points were already familiar, but others helped clear up long standing questions, for example whether iterating objects with for of and Object.keys is actually the right approach, or when to prefer interfaces over type literal aliases. Below are the tips I found most useful. Sharing them here in case they help you as well.

4.3.2 Iterating objects

Iterating objects with for (... in ...) is error prone. It will include enumerable properties from the prototype chain.

Do not use unfiltered for (... in ...) statements:

// ❌ DO NOT USE
for (const x in someObj) {
  // x could come from some parent prototype!
}

Either filter values explicitly with an if statement, or use for (... of Object.keys(...)).

// ✅ USE
for (const x in someObj) {
  if (!someObj.hasOwnProperty(x)) continue;
  // now x was definitely defined on someObj
}
for (const x of Object.keys(someObj)) { // note: for _of_!
  // now x was definitely defined on someObj
}
for (const [key, value] of Object.entries(someObj)) { // note: for _of_!
  // now key was definitely defined on someObj
}

4.4.5 Class members

No #private fields

Do not use private fields (also known as private identifiers):

// ❌ DO NOT USE
class Clazz {
  #ident = 1;
}

Instead, use TypeScript’s visibility annotations:

// ✅ USE
class Clazz {
  private ident = 1;
}

Why?

Private identifiers cause substantial emit size and performance regressions when down-leveled by TypeScript, and are unsupported before ES2015. They can only be downleveled to ES2015, not lower. At the same time, they do not offer substantial benefits when static type checking is used to enforce visibility.

4.5.2 Prefer function declarations for named functions

Prefer function declarations over arrow functions or function expressions when defining named functions.

// Prefer this: function declarations
function foo() {
  return 42;
}

// Over arrow functions
const foo = () => 42;

Arrow functions may be used, for example, when an explicit type annotation is required.

interface SearchFunction {
  (source: string, subString: string): boolean;
}

const fooSearch: SearchFunction = (source, subString) => { ... }

4.5.5 Arrow function bodies

Use arrow functions with concise bodies (i.e. expressions) or block bodies as appropriate.

// Top level functions use function declarations.
function someFunction() {
  // Block bodies are fine:
  const receipts = books.map((b: Book) => {
    const receipt = payMoney(b.price);
    recordTransaction(receipt);
    return receipt;
  });

  // Concise bodies are fine, too, if the return value is used:
  const longThings = myValues.filter(v => v.length > 1000).map(v => String(v));

  function payMoney(amount: number) {
    // function declarations are fine, but must not access `this`.
  }

  // Nested arrow functions may be assigned to a const.
  const computeTax = (amount: number) => amount * 0.12;
}

Only use a concise body if the return value of the function is actually used. The block body makes sure the return type is void then and prevents potential side effects.

// ❌ BAD: use a block body if the return value of the function is not used.
myPromise.then(v => console.log(v));

// ❌ BAD: this typechecks, but the return value still leaks.
let f: () => void;
f = () => 1;

// ✅ GOOD: return value is unused, use a block body.
myPromise.then(v => {
  console.log(v);
});

// ✅ GOOD: code may use blocks for readability.
const transformed = [1, 2, 3].map(v => {
  const intermediate = someComplicatedExpr(v);
  const more = acrossManyLines(intermediate);
  return worthWrapping(more);
});

// ✅ GOOD: explicit `void` ensures no leaked return value
myPromise.then(v => void console.log(v));

Tip: The void operator can be used to ensure an arrow function with an expression body returns undefined when the result is unused.

4.5.7 Prefer passing arrow functions as callbacks

Callbacks can be invoked with unexpected arguments that can pass a type check but still result in logical errors.

Avoid passing a named callback to a higher-order function, unless you are sure of the stability of both functions’ call signatures. Beware, in particular, of less-commonly-used optional parameters.

// ❌ BAD: Arguments are not explicitly passed, leading to unintended behavior
// when the optional `radix` argument gets the array indices 0, 1, and 2.
const numbers = ['11', '5', '10'].map(parseInt);
// > [11, NaN, 2];

Instead, prefer passing an arrow-function that explicitly forwards parameters to the named callback.

// ✅ GOOD: Arguments are explicitly passed to the callback
const numbers = ['11', '5', '3'].map((n) => parseInt(n));
// > [11, 5, 3]

// ✅ GOOD: Function is locally defined and is designed to be used as a callback
function dayFilter(element: string|null|undefined) {
  return element != null && element.endsWith('day');
}

const days = ['tuesday', undefined, 'juice', 'wednesday'].filter(dayFilter);

4.5.10 Parameter initializers

Optional function parameters may be given a default initializer to use when the argument is omitted. Initializers must not have any observable side effects. Initializers should be kept as simple as possible.

function process(name: string, extraContext: string[] = []) {}
function activate(index = 0) {}
// ❌ BAD: side effect of incrementing the counter
let globalCounter = 0;
function newId(index = globalCounter++) {}

// ❌ BAD: exposes shared mutable state, which can introduce unintended coupling
// between function calls
class Foo {
  private readonly defaultPaths: string[];
  frobnicate(paths = defaultPaths) {}
}

Use default parameters sparingly. Prefer destructuring to create readable APIs when there are more than a small handful of optional parameters that do not have a natural order.

4.8.1 String literals

Use single quotes

Ordinary string literals are delimited with single quotes ('), rather than double quotes (").

Tip: if a string contains a single quote character, consider using a template string to avoid having to escape the quote.

No line continuations

Do not use line continuations (that is, ending a line inside a string literal with a backslash) in either ordinary or template string literals. Even though ES5 allows this, it can lead to tricky errors if any trailing whitespace comes after the slash, and is less obvious to readers.

// ❌ Disallowed
const LONG_STRING = 'This is a very very very very very very very long string. \
    It inadvertently contains long stretches of spaces due to how the \
    continued lines are indented.';

// ✅ Instead, write
const LONG_STRING = 'This is a very very very very very very long string. ' +
    'It does not contain long stretches of spaces because it uses ' +
    'concatenated strings.';

const SINGLE_STRING =
    'http://it.is.also/acceptable_to_use_a_single_long_string_when_breaking_would_hinder_search_discoverability';

Template literals

Use template literals (delimited with ```) over complex string concatenation, particularly if multiple string literals are involved. Template literals may span multiple lines.

If a template literal spans multiple lines, it does not need to follow the indentation of the enclosing block, though it may if the added whitespace does not matter.

Example:

function arithmetic(a: number, b: number) {
  return `Here is a table of arithmetic operations:
${a} + ${b} = ${a + b}
${a} - ${b} = ${a - b}
${a} * ${b} = ${a * b}
${a} / ${b} = ${a / b}`;
}

4.8.3 Type coercion

TypeScript code may use the String() and Boolean() (note: no new!) functions, string template literals, or !! to coerce types.

const bool = Boolean(false);
const str = String(aNumber);
const bool2 = !!str;
const str2 = `result: ${bool2}`;

Values of enum types (including unions of enum types and other types) must not be converted to booleans with Boolean() or !!, and must instead be compared explicitly with comparison operators.


enum SupportLevel {
  NONE,
  BASIC,
  ADVANCED,
}

const level: SupportLevel = ...;
// ❌ BAD
let enabled = Boolean(level);

const maybeLevel: SupportLevel|undefined = ...;
enabled = !!maybeLevel;

---

const level: SupportLevel = ...;
// ✅ GOOD
let enabled = level !== SupportLevel.NONE;

const maybeLevel: SupportLevel|undefined = ...;
enabled = level !== undefined && level !== SupportLevel.NONE;

Code must use Number() to parse numeric values, and must check its return for NaN values explicitly, unless failing to parse is impossible from context.

// ✅ GOOD
const aNumber = Number('123');
if (!isFinite(aNumber)) throw new Error(...);

Code also must not use parseInt or parseFloat to parse numbers, except for non-base-10 strings (see below). Both of those functions ignore trailing characters in the string, which can shadow error conditions (e.g. parsing 12 dwarves as 12).

// ❌ BAD
const n = parseInt(someString, 10);  // Error prone,
const f = parseFloat(someString);    // regardless of passing a radix.

Code that requires parsing with a radix must check that its input contains only appropriate digits for that radix before calling into parseInt;

// ✅ GOOD
if (!/^[a-fA-F0-9]+$/.test(someString)) throw new Error(...);
// Needed to parse hexadecimal.
// tslint:disable-next-line:ban
const n = parseInt(someString, 16);  // Only allowed for radix != 10

Use Number() followed by Math.floor or Math.trunc (where available) to parse integer numbers:

// ✅ GOOD
let f = Number(someString);
if (isNaN(f)) handleError();
f = Math.floor(f);

Implicit coercion

Do not use explicit boolean coercions in conditional clauses that have implicit boolean coercion. Those are the conditions in an iffor and while statements.

// ✅ Prefer: implicit coercion
const foo: MyInterface|null = ...;
if (foo) {...}
while (foo) {...}

// ❌ Instead of explicit
const foo: MyInterface|null = ...;
if (!!foo) {...}
while (!!foo) {...}

Other types of values may be either implicitly coerced to booleans or compared explicitly with comparison operators:

// Explicitly comparing > 0 is OK:
if (arr.length > 0) {...}
// so is relying on boolean coercion:
if (arr.length) {...}

4.9.1 Control flow statements and blocks

Control flow statements (ifelsefordowhile, etc) always use braced blocks for the containing code, even if the body contains only a single statement. The first statement of a non-empty block must begin on its own line.

// ✅ GOOD
for (let i = 0; i < x; i++) {
  doSomethingWith(i);
}

if (x) {
  doSomethingWithALongMethodNameThatForcesANewLine(x);
}
// ❌ BAD
if (x)
  doSomethingWithALongMethodNameThatForcesANewLine(x);

for (let i = 0; i < x; i++) doSomethingWith(i);

Exception: if statements fitting on one line may elide the block.

if (x) x.doFoo();

Assignment in control statements

Prefer to avoid assignment of variables inside control statements. Assignment can be easily mistaken for equality checks inside control statements.

// ❌ BAD
if (x = someFunction()) {
  // Assignment easily mistaken with equality check
  // ...
}
// ✅ GOOD
x = someFunction();
if (x) {
  // ...
}

In cases where assignment inside the control statement is preferred, enclose the assignment in additional parenthesis to indicate it is intentional.

while ((x = someFunction())) {
  // Double parenthesis shows assignment is intentional
  // ...
}

Iterating containers

Prefer for (... of someArr) to iterate over arrays. Array.prototype.forEach and vanilla for loops are also allowed:

// ✅ GOOD
for (const x of someArr) {
  // x is a value of someArr.
}

for (let i = 0; i < someArr.length; i++) {
  // Explicitly count if the index is needed, otherwise use the for/of form.
  const x = someArr[i];
  // ...
}
for (const [i, x] of someArr.entries()) {
  // Alternative version of the above.
}

for-in loops may only be used on dict-style objects (see below for more info). Do not use for (... in ...) to iterate over arrays as it will counterintuitively give the array’s indices (as strings!), not values:

for (const x in someArray) {
  // x is the index!
}

Object.prototype.hasOwnProperty should be used in for-in loops to exclude unwanted prototype properties. Prefer for-of with Object.keysObject.values, or Object.entries over for-in when possible.

// ✅ GOOD
for (const key in obj) {
  if (!obj.hasOwnProperty(key)) continue;
  doWork(key, obj[key]);
}
for (const key of Object.keys(obj)) {
  doWork(key, obj[key]);
}
for (const value of Object.values(obj)) {
  doWorkValOnly(value);
}
for (const [key, value] of Object.entries(obj)) {
  doWork(key, value);
}

4.9.3 Exception handling

Exceptions are an important part of the language and should be used whenever exceptional cases occur.

Custom exceptions provide a great way to convey additional error information from functions. They should be defined and used wherever the native Error type is insufficient.

Prefer throwing exceptions over ad-hoc error-handling approaches (such as passing an error container reference type, or returning an object with an error property).

Instantiate errors using new

Always use new Error() when instantiating exceptions, instead of just calling Error(). Both forms create a new Error instance, but using new is more consistent with how other objects are instantiated.

// ✅ Prefer new Error
throw new Error('Foo is not a valid bar.');

// ❌ Instead of just Error
throw Error('Foo is not a valid bar.');

Only throw errors

JavaScript (and thus TypeScript) allow throwing or rejecting a Promise with arbitrary values. However if the thrown or rejected value is not an Error, it does not populate stack trace information, making debugging hard. This treatment extends to Promise rejection values as Promise.reject(obj) is equivalent to throw obj; in async functions.

// ❌ bad: does not get a stack trace.
throw 'oh noes!';
// For promises
new Promise((resolve, reject) => void reject('oh noes!'));
Promise.reject();
Promise.reject('oh noes!');

Instead, only throw (subclasses of) Error:

// ✅ Throw only Errors
throw new Error('oh noes!');
// ... or subtypes of Error.
class MyError extends Error {}
throw new MyError('my oh noes!');
// For promises
new Promise((resolve) => resolve()); // No reject is OK.
new Promise((resolve, reject) => void reject(new Error('oh noes!')));
Promise.reject(new Error('oh noes!'));

Catching and rethrowing

When catching errors, code should assume that all thrown errors are instances of Error.

function assertIsError(e: unknown): asserts e is Error {
  if (!(e instanceof Error)) throw new Error("e is not an Error");
}

try {
  doSomething();
} catch (e: unknown) {
  // All thrown errors must be Error subtypes. Do not handle
  // other possible values unless you know they are thrown.
  assertIsError(e);
  displayError(e.message);
  // or rethrow:
  throw e;
}

Exception handlers must not defensively handle non-Error types unless the called API is conclusively known to throw non-Errors in violation of the above rule. In that case, a comment should be included to specifically identify where the non-Errors originate.

try {
  badApiThrowingStrings();
} catch (e: unknown) {
  // Note: bad API throws strings instead of errors.
  if (typeof e === 'string') { ... }
}

Why?

Avoid overly defensive programming. Repeating the same defenses against a problem that will not exist in most code leads to boiler-plate code that is not useful.

4.9.4 Switch statements

All switch statements must contain a default statement group, even if it contains no code. The default statement group must be last.

switch (x) {
  case Y:
    doSomethingElse();
    break;
  default:
    // nothing to do.
}

Within a switch block, each statement group either terminates abruptly with a break, a return statement, or by throwing an exception. Non-empty statement groups (case ...must not fall through (enforced by the compiler):

switch (x) {
  case X:
    doSomething();
    // fall through - not allowed!
  case Y:
    // ...
}

Empty statement groups are allowed to fall through:

switch (x) {
  case X:
  case Y:
    doSomething();
    break;
  default: // nothing to do.
}

4.9.5 Equality checks

Always use triple equals (===) and not equals (!==). The double equality operators cause error prone type coercions that are hard to understand and slower to implement for JavaScript Virtual Machines. See also the JavaScript equality table.

// ❌ BAD
if (foo == 'bar' || baz != bam) {
  // Hard to understand behaviour due to type coercion.
}

// ✅ GOOD
if (foo === 'bar' || baz !== bam) {
  // All good here.
}

Exception: Comparisons to the literal null value may use the == and != operators to cover both null and undefined values.

if (foo == null) {
  // Will trigger when foo is null or undefined.
}

4.9.6 Type and non-nullability assertions

Type assertions (x as SomeType) and non-nullability assertions (y!) are unsafe. Both only silence the TypeScript compiler, but do not insert any runtime checks to match these assertions, so they can cause your program to crash at runtime.

Because of this, you should not use type and non-nullability assertions without an obvious or explicit reason for doing so.

Instead of the following:

// ❌ BAD
(x as Foo).foo();

y!.bar();

When you want to assert a type or non-nullability the best answer is to explicitly write a runtime check that performs that check.

// // ✅ GOOD
// assuming Foo is a class.
if (x instanceof Foo) {
  x.foo();
}

if (y) {
  y.bar();
}

Sometimes due to some local property of your code you can be sure that the assertion form is safe. In those situations, you should add clarification to explain why you are ok with the unsafe behavior:

// x is a Foo, because ...
(x as Foo).foo();

// y cannot be null, because ...
y!.bar();

If the reasoning behind a type or non-nullability assertion is obvious, the comments may not be necessary. For example, generated proto code is always nullable, but perhaps it is well-known in the context of the code that certain fields are always provided by the backend. Use your judgement.

Type assertion syntax

Type assertions must use the as syntax (as opposed to the angle brackets syntax). This enforces parentheses around the assertion when accessing a member.

// ❌ BAD
const x = (<Foo>z).length;
const y = <Foo>z.length;

// ✅ GOOD
// z must be Foo because ...
const x = (z as Foo).length;

Double assertions

From the TypeScript handbook, TypeScript only allows type assertions which convert to a more specific or less specific version of a type. Adding a type assertion (x as Foo) which does not meet this criteria will give the error: Conversion of type 'X' to type 'Y' may be a mistake because neither type sufficiently overlaps with the other.

This rule prevents “impossible” coercions like:

const x = "hello" as number;
// Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the  expression to 'unknown' first.

If you are sure an assertion is safe, you can perform a double assertion. This involves casting through unknown since it is less specific than all types.

// x is a Foo here, because...
(x as unknown as Foo).fooMethod();

Use unknown (instead of any or {}) as the intermediate type.

Type assertions and object literals

Use type annotations (: Foo) instead of type assertions (as Foo) to specify the type of an object literal. This allows detecting refactoring bugs when the fields of an interface change over time.

// ❌ Do not use as
interface Foo {
  bar: number;
  baz?: string;  // was "bam", but later renamed to "baz".
}

const foo = {
  bar: 123,
  bam: 'abc',  // no error!
} as Foo;

function func() {
  return {
    bar: 123,
    bam: 'abc',  // no error!
  } as Foo;
}

// ✅ Use the type notation
interface Foo {
  bar: number;
  baz?: string;
}

const foo: Foo = {
  bar: 123,
  bam: 'abc',  // complains about "bam" not being defined on Foo.
};

function func(): Foo {
  return {
    bar: 123,
    bam: 'abc',   // complains about "bam" not being defined on Foo.
  };
}

5.1.1 Naming style

TypeScript expresses information in types, so names should not be decorated with information that is included in the type. (See also Testing Blog for more about what not to include.)

Some concrete examples of this rule:

  • Do not use trailing or leading underscores for private properties or methods.
  • Do not use the opt_ prefix for optional parameters.
  • Do not mark interfaces specially (~~IMyInterface~~ or ~~MyFooInterface~~) unless it’s idiomatic in its environment. When introducing an interface for a class, give it a name that expresses why the interface exists in the first place (e.g. class TodoItem and interface TodoItemStorage if the interface expresses the format used for storage/serialization in JSON).
  • Suffixing Observables with $ is a common external convention and can help resolve confusion regarding observable values vs concrete values. Judgement on whether this is a useful convention is left up to individual teams, but should be consistent within projects.

5.1.2 Descriptive names

Names must be descriptive and clear to a new reader. Do not use abbreviations that are ambiguous or unfamiliar to readers outside your project, and do not abbreviate by deleting letters within a word.

  • Exception: Variables that are in scope for 10 lines or fewer, including arguments that are not part of an exported API, may use short (e.g. single letter) variable names.
// ✅ Good identifiers:
errorCount          // No abbreviation.
dnsConnectionIndex  // Most people know what "DNS" stands for.
referrerUrl         // Ditto for "URL".
customerId          // "Id" is both ubiquitous and unlikely to be misunderstood.

// Disallowed identifiers:
n                   // Meaningless.
nErr                // Ambiguous abbreviation.
nCompConns          // Ambiguous abbreviation.
wgcConnections      // Only your group knows what this stands for.
pcReader            // Lots of things can be abbreviated "pc".
cstmrId             // Deletes internal letters.
kSecondsPerDay      // Do not use Hungarian notation.
customerID          // Incorrect camelcase of "ID".

5.1.3 Camel case

Treat abbreviations like acronyms in names as whole words, i.e. use loadHttpUrl, not ~~loadHTTPURL~~, unless required by a platform name (e.g. XMLHttpRequest).

5.2 Rules by identifier type

Most identifier names should follow the casing in the table below, based on the identifier’s type.

Style Category
UpperCamelCase class / interface / type / enum / decorator / type parameters / component functions in TSX / JSXElement type parameter
lowerCamelCase variable / parameter / function / method / property / module alias
CONSTANT_CASE global constant values, including enum values. See Constants below.
#ident private identifiers are never used.

5.2.3 _ prefix/suffix

Identifiers must not use _ as a prefix or suffix.

This also means that _ must not be used as an identifier by itself (e.g. to indicate a parameter is unused).

Tip: If you only need some of the elements from an array (or TypeScript tuple), you can insert extra commas in a destructuring statement to ignore in-between elements:

const [a, , b] = [1, 5, 10];  // a <- 1, b <- 10

5.2.5 Constants

ImmutableCONSTANT_CASE indicates that a value is intended to not be changed, and may be used for values that can technically be modified (i.e. values that are not deeply frozen) to indicate to users that they must not be modified.

const UNIT_SUFFIXES = {
  'milliseconds': 'ms',
  'seconds': 's',
};
// Even though per the rules of JavaScript UNIT_SUFFIXES is
// mutable, the uppercase shows users to not modify it.

A constant can also be a static readonly property of a class.

class Foo {
  private static readonly MY_SPECIAL_NUMBER = 5;

  bar() {
    return 2 * Foo.MY_SPECIAL_NUMBER;
  }
}

Global: Only symbols declared on the module level, static fields of module level classes, and values of module level enums, may use CONST_CASE. If a value can be instantiated more than once over the lifetime of the program (e.g. a local variable declared within a function, or a static field on a class nested in a function) then it must use lowerCamelCase.

6.2. Undefined and null

TypeScript supports undefined and null types. Nullable types can be constructed as a union type (string|null); similarly with undefined. There is no special syntax for unions of undefined and null.

TypeScript code can use either undefined or null to denote absence of a value, there is no general guidance to prefer one over the other. Many JavaScript APIs use undefined (e.g. Map.get), while many DOM and Google APIs use null (e.g. Element.getAttribute), so the appropriate absent value depends on the context.

6.2.1 Nullable/undefined type aliases

Type aliases must not include |null or |undefined in a union type. Nullable aliases typically indicate that null values are being passed around through too many layers of an application, and this clouds the source of the original issue that resulted in null. They also make it unclear when specific values on a class or interface might be absent.

Instead, code must only add |null or |undefined when the alias is actually used. Code should deal with null values close to where they arise, using the above techniques.

// ❌ Bad
type CoffeeResponse = Latte|Americano|undefined;

class CoffeeService {
  getLatte(): CoffeeResponse { ... };
}

// ✅ Better
type CoffeeResponse = Latte|Americano;

class CoffeeService {
  getLatte(): CoffeeResponse|undefined { ... };
}

6.2.2 Prefer optional over |undefined

In addition, TypeScript supports a special construct for optional parameters and fields, using ?:

interface CoffeeOrder {
  sugarCubes: number;
  milk?: Whole|LowFat|HalfHalf;
}

function pourCoffee(volume?: Milliliter) { ... }

Optional parameters implicitly include |undefined in their type. However, they are different in that they can be left out when constructing a value or calling a method. For example, {sugarCubes: 1} is a valid CoffeeOrder because milk is optional.

Use optional fields (on interfaces or classes) and parameters rather than a |undefined type.

For classes preferably avoid this pattern altogether and initialize as many fields as possible.

class MyClass {
  field = '';
}

6.4 Prefer interfaces over type literal aliases

TypeScript supports type aliases for naming a type expression. This can be used to name primitives, unions, tuples, and any other types.

However, when declaring types for objects, use interfaces instead of a type alias for the object literal expression.

// ✅ Prefer interface
interface User {
  firstName: string;
  lastName: string;
}

// ❌ Over type
type User = {
  firstName: string,
  lastName: string,
}

Why?

These forms are nearly equivalent, so under the principle of just choosing one out of two forms to prevent variation, we should choose one. Additionally, there are also interesting technical reasons to prefer interface. That page quotes the TypeScript team lead: Honestly, my take is that it should really just be interfaces for anything that they can model. There is no benefit to type aliases when there are so many issues around display/perf.

6.7 Mapped and conditional types

TypeScript’s mapped types and conditional types allow specifying new types based on other types. TypeScript’s standard library includes several type operators based on these (RecordPartialReadonly etc).

These type system features allow succinctly specifying types and constructing powerful yet type safe abstractions. They come with a number of drawbacks though:

  • Compared to explicitly specifying properties and type relations (e.g. using interfaces and extension, see below for an example), type operators require the reader to mentally evaluate the type expression. This can make programs substantially harder to read, in particular combined with type inference and expressions crossing file boundaries.

  • Mapped & conditional types’ evaluation model, in particular when combined with type inference, is underspecified, not always well understood, and often subject to change in TypeScript compiler versions. Code can  compile or seem to give the right results. This increases future support cost of code using type operators.

    accidentally

  • Mapped & conditional types are most powerful when deriving types from complex and/or inferred types. On the flip side, this is also when they are most prone to create hard to understand and maintain programs.

  • Some language tooling does not work well with these type system features. E.g. your IDE’s find references (and thus rename property refactoring) will not find properties in a Pick<T, Keys> type, and Code Search won’t hyperlink them.

The style recommendation is:

  • Always use the simplest type construct that can possibly express your code.
  • A little bit of repetition or verbosity is often much cheaper than the long term cost of complex type expressions.
  • Mapped & conditional types may be used, subject to these considerations.

For example, TypeScript’s builtin Pick<T, Keys> type allows creating a new type by subsetting another type T, but simple interface extension can often be easier to understand.

interface User {
  shoeSize: number;
  favoriteIcecream: string;
  favoriteChocolate: string;
}

// ❌ FoodPreferences has favoriteIcecream and favoriteChocolate, but not shoeSize.
type FoodPreferences = Pick<User, 'favoriteIcecream'|'favoriteChocolate'>;

This is equivalent to spelling out the properties on FoodPreferences:

// ✅ GOOD
interface FoodPreferences {
  favoriteIcecream: string;
  favoriteChocolate: string;
}

To reduce duplication, User could extend FoodPreferences, or (possibly better) nest a field for food preferences:

interface FoodPreferences { /* as above */ }
interface User extends FoodPreferences {
  shoeSize: number;
  // also includes the preferences.
}

Using interfaces here makes the grouping of properties explicit, improves IDE support, allows better optimization, and arguably makes the code easier to understand.

Published 21 Dec 2025

Read more

 — Typescript types vs interface
 — Upgrade to Angular 20 from Angular 13 - Part 2: Angular 15
 — Upgrade to Angular 20 from Angular 13 - Part 1: Angular 14
 — Typescript this and implement debounce
 — Classnames implementation in TypeScript